Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3dafc15
test(claude): the CLI simulator ends its stream cleanly when the CLI …
danshapiro Oct 22, 2026
7e2b75e
fix(freshclaude): the sidecar exits when its SDK stream ends (G02, G23)
danshapiro Oct 22, 2026
94783e0
test(freshclaude): the e2e Deny lane expects no per-turn idle
danshapiro Oct 22, 2026
772fcce
test(claude): the CLI simulator can hang a /compact
danshapiro Oct 22, 2026
aceaa17
feat(freshclaude): exited status, dead-session record and Resume (G05)
danshapiro Oct 22, 2026
9e6d79b
test(freshclaude): identity-persistence turns wait for their completion
danshapiro Oct 22, 2026
2e01c03
feat(freshclaude): created only after the CLI boots; typed boot failures
danshapiro Oct 22, 2026
fe5799e
test(electron): the checkout-free sidecar check names its catalog fra…
danshapiro Oct 22, 2026
e639d5e
test(freshclaude): e2e specs read the v2 boot in the sidecar create log
danshapiro Oct 22, 2026
9af686d
fix(freshclaude): the boot catalog leaves out the CLI's terminal-only…
danshapiro Oct 23, 2026
469a609
fix(freshclaude): a session is registered before its consumer reads a…
danshapiro Oct 23, 2026
01f00c9
fix(freshclaude): a resume during the reap gets no exit report
danshapiro Oct 23, 2026
a1b2c1c
docs(freshclaude): only the legacy fakes send an idle
danshapiro Oct 23, 2026
83f179b
test(kilroy): the fake kilroy runtime ends a turn with its result
danshapiro Oct 23, 2026
f0fbd23
test(e2e): a crashed kilroy pane recovers through a reload too
danshapiro Oct 23, 2026
6e64ed9
refactor(freshclaude): dead-session records and the create line leave…
danshapiro Oct 23, 2026
c7cce34
fix(freshclaude): the exit banner points at Resume
danshapiro Oct 23, 2026
1aa9598
Merge origin/feat/freshclaude-parity into feat/freshclaude-core-lifec…
danshapiro Oct 23, 2026
f88f419
Merge origin/feat/freshclaude-parity (1691c4ec9) into feat/freshclaud…
danshapiro Oct 23, 2026
2da423f
test(freshclaude): the default-model e2e spec reads the v2 boot in th…
danshapiro Oct 23, 2026
0fa4083
test(claude-rollback): the unreadable-store undo reads the fork from …
danshapiro Oct 23, 2026
f42a579
test(claude-sidecar): a sidecar exiting on its own keeps its exit cod…
danshapiro Oct 23, 2026
4b84de2
test(claude): pin the one sidecar stop for unrequested ends and reque…
danshapiro Oct 23, 2026
549c05a
fix(claude): the detached tree-death escalation backs off between win…
danshapiro Oct 23, 2026
c794647
docs(claude): say what an unrequested end's report waits for, and pin it
danshapiro Oct 23, 2026
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
113 changes: 113 additions & 0 deletions crates/freshell-claude-sidecar/boot.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// boot.mjs — how a `create` request becomes an SDK launch, and how a launch
// the CLI refused is reported (docs/plans/2026-10-22-freshclaude-core-runtime.md
// §4.2 and §5.1, protocol v2). Pure: index.mjs keeps the side effects.
// ---------------------------------------------------------------------------
// The sidecar answers `created` only once the CLI has booted
// (`initializationResult()` resolved, T2). A boot the CLI refused is answered
// with `create.failed { code, message }` instead, and the sidecar exits with
// BOOT_FAILED_EXIT_CODE. The codes come from the pinned CLI's exact refusal
// strings (claude-cli-contract.test.ts pins them, T4 and T5):
//
// SESSION_ID_IN_USE `Session ID X is already in use.` on the CLI's
// stderr, then exit code 1 (T4)
// CONVERSATION_NOT_FOUND `No conversation found with session ID: X` (T5)
// RESUME_POINT_NOT_FOUND `No message found with message.uuid of: U` (T5)
// FORK_GUARD_REFUSED `Resume rejected by --resume-drops-turn: …` (T5)
// BOOT_FAILED anything else (for one, a CLAUDE_CMD that is not
// the CLI)
// SIDECAR_PROTOCOL_MISMATCH the request does not speak SIDECAR_PROTOCOL

/** The stdio protocol version this sidecar speaks (`create.protocol`, `created.protocol`). */
export const SIDECAR_PROTOCOL = 2

/** The sidecar's exit code after a `create.failed`: it hosts no session. */
export const BOOT_FAILED_EXIT_CODE = 2

/** The most characters of refusal text a `create.failed` carries. */
export const BOOT_FAILURE_MESSAGE_MAX_CHARS = 1000

/** The refusals the CLI reports by name, each matched on its own line of evidence. */
const REFUSALS = [
{ code: 'SESSION_ID_IN_USE', pattern: /Session ID \S+ is already in use\./ },
{ code: 'CONVERSATION_NOT_FOUND', pattern: /No conversation found with session ID:.*/ },
{ code: 'RESUME_POINT_NOT_FOUND', pattern: /No message found with message\.uuid of:.*/ },
{ code: 'FORK_GUARD_REFUSED', pattern: /Resume rejected by --resume-drops-turn:.*/ },
]

const DIAGNOSTIC_PREFIX = '[ede_diagnostic]'

const BOOT_FAILED_COPY = 'The Claude CLI did not start.'

/** A `create` the sidecar cannot launch: its own code, message and no SDK call. */
export class CreateRequestError extends Error {
constructor(code, message) {
super(message)
this.name = 'CreateRequestError'
this.code = code
}
}

/**
* The SDK `query()` options that pick which conversation the CLI boots
* (protocol v2 `create.boot`). `sessionId` is the id Rust minted for this
* session, when it minted one: a fresh boot runs under it and a fork writes
* the child under it. A resume never sets `sessionId` (the SDK refuses
* sessionId with resume unless forking).
*/
export function bootOptions(boot, sessionId) {
switch (boot?.kind) {
case 'fresh':
return sessionId ? { sessionId } : {}
case 'resume':
if (!nonEmpty(boot.resume)) throw invalidBoot('a resume boot names no conversation')
return { resume: boot.resume }
case 'fork':
if (!nonEmpty(boot.resume)) throw invalidBoot('a fork boot names no parent conversation')
return {
resume: boot.resume,
forkSession: true,
...(nonEmpty(boot.resumeSessionAt) ? { resumeSessionAt: boot.resumeSessionAt } : {}),
...(sessionId ? { sessionId } : {}),
...(nonEmpty(boot.dropsTurn) ? { resumeDropsTurn: boot.dropsTurn } : {}),
}
default:
throw invalidBoot(`unknown boot kind ${JSON.stringify(boot?.kind ?? null)}`)
}
}

/**
* Why the CLI refused to boot, as `{ code, message }`. The evidence is what
* the boot produced: `error` (the `initializationResult()` rejection),
* `resultErrors` (the `errors` of the `error_during_execution` result the CLI
* sends before it rejects, T5) and `stderr` (the CLI's own output, where the
* "already in use" refusal appears, T4). A named refusal's message is the
* CLI's own sentence; anything else is BOOT_FAILED with the error's text.
*/
export function classifyBootError({ error, resultErrors = [], stderr = '' } = {}) {
const results = resultErrors
.filter((text) => typeof text === 'string')
.map((text) => text.trim())
.filter((text) => text && !text.startsWith(DIAGNOSTIC_PREFIX))
const errorMessage = error === undefined ? '' : String(error?.message || error)
const evidence = [...results, errorMessage, ...String(stderr).split('\n')]
.flatMap((text) => text.split('\n'))
for (const { code, pattern } of REFUSALS) {
for (const line of evidence) {
const match = pattern.exec(line)
if (match) return { code, message: cut(match[0].trim()) }
}
}
return { code: 'BOOT_FAILED', message: cut(results.join('\n') || errorMessage || BOOT_FAILED_COPY) }
}

function invalidBoot(message) {
return new CreateRequestError('BOOT_FAILED', `invalid create request: ${message}`)
}

function nonEmpty(value) {
return typeof value === 'string' && value.length > 0
}

function cut(text) {
return text.slice(0, BOOT_FAILURE_MESSAGE_MAX_CHARS)
}
Loading
Loading