Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions script/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,35 @@ async function gitTagAndRelease(newVersion: string, changelog: string): Promise<
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
await $`git config user.name "github-actions[bot]"`
await $`git add package.json`
await $`git commit -m "release: v${newVersion}"`
await $`git tag v${newVersion}`

// Commit only if there are staged changes (idempotent)
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
if (hasStagedChanges.exitCode !== 0) {
await $`git commit -m "release: v${newVersion}"`
} else {
console.log("No changes to commit (version already updated)")
}

// Tag only if it doesn't exist (idempotent)
const tagExists = await $`git rev-parse v${newVersion}`.nothrow()
if (tagExists.exitCode !== 0) {
await $`git tag v${newVersion}`
} else {
console.log(`Tag v${newVersion} already exists`)
}

// Push (idempotent - git push is already idempotent)
await $`git push origin HEAD --tags`

// Create release only if it doesn't exist (idempotent)
console.log("\nCreating GitHub release...")
const releaseNotes = changelog || "No notable changes"
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
const releaseExists = await $`gh release view v${newVersion}`.nothrow()
if (releaseExists.exitCode !== 0) {
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
} else {
console.log(`Release v${newVersion} already exists`)
}
}

async function checkVersionExists(version: string): Promise<boolean> {
Expand Down
82 changes: 73 additions & 9 deletions src/hooks/session-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,29 +211,93 @@ async function recoverThinkingDisabledViolation(
return false
}

const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])

function hasNonEmptyOutput(msg: MessageData): boolean {
const parts = msg.parts
if (!parts || parts.length === 0) return false

return parts.some((p) => {
if (THINKING_TYPES.has(p.type)) return false
if (p.type === "step-start" || p.type === "step-finish") return false
if (p.type === "text" && p.text && p.text.trim()) return true
if (p.type === "tool_use" && p.id) return true
if (p.type === "tool_result") return true
return false
})
}

function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
const isLastMessage = i === msgs.length - 1
const isAssistant = msg.info?.role === "assistant"

if (isLastMessage && isAssistant) continue

if (!hasNonEmptyOutput(msg)) {
return msg
}
}
return null
}

async function recoverEmptyContentMessage(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
directory: string
): Promise<boolean> {
const messageID = failedAssistantMsg.info?.id
const parentMsgID = failedAssistantMsg.info?.parentID
try {
const messagesResp = await client.session.messages({
path: { id: sessionID },
query: { directory },
})
const msgs = (messagesResp as { data?: MessageData[] }).data

if (!messageID) {
return false
}
if (!msgs || msgs.length === 0) return false

// Revert to parent message (delete the empty message)
const revertTargetID = parentMsgID || messageID
const emptyMsg = findEmptyContentMessage(msgs) || failedAssistantMsg
const messageID = emptyMsg.info?.id
if (!messageID) return false

try {
const existingParts = emptyMsg.parts || []
const hasOnlyThinkingOrMeta = existingParts.length > 0 && existingParts.every(
(p) => THINKING_TYPES.has(p.type) || p.type === "step-start" || p.type === "step-finish"
)

if (hasOnlyThinkingOrMeta) {
const strippedParts: MessagePart[] = [{ type: "text", text: "(interrupted)" }]

try {
// @ts-expect-error - Experimental API
await client.message?.update?.({
path: { id: messageID },
body: { parts: strippedParts },
})
return true
} catch {
// message.update not available
}

try {
// @ts-expect-error - Experimental API
await client.session.patch?.({
path: { id: sessionID },
body: { messageID, parts: strippedParts },
})
return true
} catch {
// session.patch not available
}
}

const revertTargetID = emptyMsg.info?.parentID || messageID
await client.session.revert({
path: { id: sessionID },
body: { messageID: revertTargetID },
query: { directory },
})

return true
} catch {
return false
Expand Down