Skip to content
Open
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
49 changes: 49 additions & 0 deletions src/lib/claude-cli-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,16 @@ export async function streamClaudeCodeCli(
let unlistenData: UnlistenFn | undefined
let unlistenDone: UnlistenFn | undefined
let finished = false
let aborted = signal?.aborted ?? false
// Track whether any assistant text was received — used to detect the
// silent-exit case where the CLI exits 0 but emits no content.
let emittedToken = false
// Completion promise: resolves when finishWith() fires so the caller
// awaits the full round-trip rather than returning after spawn.
let resolveCompletion: () => void = () => {}
const completion = new Promise<void>((resolve) => {
resolveCompletion = resolve
})

// Diagnostic capture for failure paths. The Rust side emits every
// stdout line; lines the parser doesn't recognize (non-JSON,
Expand Down Expand Up @@ -173,22 +183,29 @@ export async function streamClaudeCodeCli(
finished = true
cleanup()
cb()
resolveCompletion()
}

const abortListener = () => {
aborted = true
void invoke("claude_cli_kill", { streamId }).catch(() => {
// Kill is best-effort; if the process already exited, the Rust
// side returns Ok and the done handler fires normally.
})
finishWith(onDone)
}
if (aborted) {
finishWith(onDone)
return
}
signal?.addEventListener("abort", abortListener)

try {
// Listen FIRST so we don't miss the very first event on fast CLIs.
unlistenData = await listen<string>(`claude-cli:${streamId}`, (event) => {
const token = parse(event.payload)
if (token !== null) {
emittedToken = true
onToken(token)
} else {
// Parser didn't recognize this line. Stash it in case the
Expand All @@ -198,6 +215,10 @@ export async function streamClaudeCodeCli(
captureUnparsed(event.payload)
}
})
if (aborted || finished) {
cleanup()
return
}

unlistenDone = await listen<{ code: number | null; stderr: string }>(
`claude-cli:${streamId}:done`,
Expand All @@ -210,18 +231,46 @@ export async function streamClaudeCodeCli(
new Error(buildExitError(code, stderr, unparsedLines.join("\n"))),
),
)
} else if (!emittedToken) {
// CLI exited successfully but produced no assistant text.
// Surface this as an explicit error so the ingest pipeline
// retries rather than silently writing an empty stub page.
const details = stderr || unparsedLines.join("\n").trim()
finishWith(() =>
onError(new Error(
details
? `Claude Code CLI completed but returned no content:\n${details}`
: "Claude Code CLI completed but returned no content. Try running `claude -p` in a terminal to inspect the output, or switch to the Anthropic API in Settings.",
)),
)
} else {
finishWith(onDone)
}
},
)
if (aborted || finished) {
cleanup()
return
}

const payload: SpawnPayload = {
streamId,
model: config.model,
messages,
}
await invoke("claude_cli_spawn", payload)
if (aborted || signal?.aborted) {
aborted = true
await invoke("claude_cli_kill", { streamId }).catch(() => {})
finishWith(onDone)
return
}
// Wait for the done event to be processed before returning.
// Without this await the caller sees an empty analysis buffer
// because streamClaudeCodeCli() resolves immediately after spawn
// while the CLI subprocess is still running. (Same race that was
// fixed for the Codex CLI transport in #238.)
await completion
} catch (err) {
finishWith(() => {
const message = err instanceof Error ? err.message : String(err)
Expand Down