Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b56c86f
refactor(freshagent): shared quiet_deadman and attention modules
danshapiro Oct 23, 2026
2a1e4a9
feat(freshclaude): uuid-stamped sends and lifecycle facts
danshapiro Oct 23, 2026
dc80ef8
feat(freshclaude): per-command turn ledger in Rust (G08, G23, G21)
danshapiro Oct 23, 2026
2ccb759
feat(freshclaude): Rust mints every attention edge; Stop latch (G03, …
danshapiro Oct 23, 2026
e9cd6b7
test(freshclaude): raise the hidden-pane approval with a send, not an…
danshapiro Oct 23, 2026
4aefaf2
Merge origin/main into freshclaude turn and alert work
danshapiro Oct 23, 2026
9dc3419
fix(freshclaude): retain outstanding lifecycle ids
danshapiro Oct 23, 2026
a6bf68e
perf(freshclaude): per-session stdin writer (G27)
danshapiro Oct 23, 2026
57d1222
test(freshclaude): cover lifecycle capacity backlog
danshapiro Oct 23, 2026
9357be4
fix(freshclaude): order pending cards and attention edges atomically
danshapiro Oct 23, 2026
6131181
test: remove SDK protocol comment scanner
danshapiro Oct 23, 2026
7283201
fix(freshclaude): preserve exited after a delayed write failure
danshapiro Oct 23, 2026
8c240d9
fix(freshclaude): drain pending attention when an ended runtime is ev…
danshapiro Oct 23, 2026
0a06588
test(freshclaude): keep scoped log captures reliable across threads
danshapiro Oct 23, 2026
194c31b
test(freshclaude): isolate approval cancellation from turn refresh
danshapiro Oct 23, 2026
3da2f0b
test(freshclaude): require correlated interrupt failure acknowledgements
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
337 changes: 117 additions & 220 deletions crates/freshell-claude-sidecar/index.mjs

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions crates/freshell-claude-sidecar/lifecycle-frames.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// The pure mapping from the CLI's lifecycle messages to the sidecar's fact
// frames (docs/plans/2026-10-22-freshclaude-core-runtime.md §5.1, C16). The
// server folds these facts into its turn accounting; none of them is content
// for the client. The core runtime owns these kinds (§9.1 N6): per-command
// lifecycle and replays, session state, the end of a status, and liveness
// heartbeats. Content (assistant, stream events, transcript announcements,
// notices) is sdk-message-frames.mjs's.
//
// Facts are forwarded only for commands this sidecar sent: the CLI also
// queues and replays uuids it minted itself (setModel's /model command, cron
// triggers, auto-resume continuations, T8), which the server never knew.
//
// sdk.command {sessionId, uuid, state: queued|started|completed|cancelled|discarded|replayed}
// — the command's lifecycle (T8); `replayed` is its first replay
// sdk.session.state {sessionId, state: running|idle|requires_action} (T9)
// sdk.status {sessionId, status: null, compactResult?, compactError?, permissionMode?}
// — a status ended (T15); {status: 'requesting'} is a model request
// sdk.heartbeat {sessionId, kind: tool|task|retry, toolUseId?, elapsedMs?}
// — liveness only, at most one per kind every HEARTBEAT_INTERVAL_MS (T17)

/** At most one heartbeat of each kind is forwarded in this many milliseconds. */
export const HEARTBEAT_INTERVAL_MS = 5000

const COMMAND_STATES = new Set(['queued', 'started', 'completed', 'cancelled', 'discarded'])
const TERMINAL_COMMAND_STATES = new Set(['completed', 'cancelled', 'discarded'])
const SESSION_STATES = new Set(['running', 'idle', 'requires_action'])
const TASK_SUBTYPES = new Set(['task_started', 'task_progress', 'task_updated', 'task_notification'])

/** Leading-edge rate limit per heartbeat kind: the first beat passes, then one per interval. */
export function createHeartbeatLimiter(intervalMs = HEARTBEAT_INTERVAL_MS) {
const lastAdmitted = new Map()
return {
admit(kind, now) {
const last = lastAdmitted.get(kind)
if (last !== undefined && now - last < intervalMs) return false
lastAdmitted.set(kind, now)
return true
},
}
}

/** One session's lifecycle state: its outstanding sent commands and heartbeat allowance. */
export function createLifecycleState() {
return { sent: new Map(), heartbeats: createHeartbeatLimiter() }
}

/** The sidecar is about to send this command to the CLI; keep it until terminal lifecycle. */
export function rememberSend(state, uuid) {
state.sent.delete(uuid)
state.sent.set(uuid, { replayed: false })
}

/** The facts one SDK message carries (none for any other kind). `now` is in milliseconds. */
export function lifecycleFrames(sessionId, msg, state, now = Date.now()) {
switch (msg?.type) {
case 'command_lifecycle': return commandFrames(sessionId, msg, state)
case 'user': return replayFrames(sessionId, msg, state)
case 'tool_progress': return heartbeatFrames(sessionId, state, now, toolHeartbeat(msg))
case 'system': return systemFrames(sessionId, msg, state, now)
default: return []
}
}

/**
* The sent commands an interrupt receipt says will still run (T12, T13), or
* undefined when the CLI sent no receipt (older CLIs resolve undefined).
*/
export function stillQueuedOf(receipt, state) {
if (!Array.isArray(receipt?.still_queued)) return undefined
return receipt.still_queued.filter((uuid) => state.sent.has(uuid))
}

function commandFrames(sessionId, msg, state) {
if (!state.sent.has(msg.command_uuid) || !COMMAND_STATES.has(msg.state)) return []
const frame = { type: 'sdk.command', sessionId, uuid: msg.command_uuid, state: msg.state }
// Only outstanding commands are needed to recognize future lifecycle and
// interrupt-receipt facts. Retire terminal ids here so long-lived sessions
// do not accumulate completed history, while an arbitrarily large queue
// remains fully attributable to the server's turn ledger.
if (TERMINAL_COMMAND_STATES.has(msg.state)) state.sent.delete(msg.command_uuid)
return [frame]
}

/** The first replay of a sent command: the CLI took it as a user message (T8). */
function replayFrames(sessionId, msg, state) {
if (msg.isReplay !== true) return []
const sent = state.sent.get(msg.uuid)
if (!sent || sent.replayed) return []
sent.replayed = true
return [{ type: 'sdk.command', sessionId, uuid: msg.uuid, state: 'replayed' }]
}

function systemFrames(sessionId, msg, state, now) {
if (msg.subtype === 'session_state_changed') {
return SESSION_STATES.has(msg.state) ? [{ type: 'sdk.session.state', sessionId, state: msg.state }] : []
}
if (msg.subtype === 'status') return statusFrames(sessionId, msg)
if (msg.subtype === 'api_retry') return heartbeatFrames(sessionId, state, now, { kind: 'retry' })
if (TASK_SUBTYPES.has(msg.subtype)) {
return heartbeatFrames(sessionId, state, now, { kind: 'task', ...(typeof msg.tool_use_id === 'string' ? { toolUseId: msg.tool_use_id } : {}) })
}
return []
}

/**
* A status ended (`null`: a compaction's outcome or a new permission mode,
* T15) or a model request started (`requesting`). `compacting` is content:
* the client shows it, and sdk-message-frames.mjs forwards it.
*/
function statusFrames(sessionId, msg) {
if (msg.status === 'requesting') return [{ type: 'sdk.status', sessionId, status: 'requesting' }]
if (msg.status !== null) return []
return [{
type: 'sdk.status',
sessionId,
status: null,
...(msg.compact_result !== undefined ? { compactResult: msg.compact_result } : {}),
...(msg.compact_error !== undefined ? { compactError: msg.compact_error } : {}),
...(msg.permissionMode !== undefined ? { permissionMode: msg.permissionMode } : {}),
}]
}

/**
* A running tool's progress. A heartbeat is stamped with an id of its own and
* names the tool it belongs to as its parent (T17).
*/
function toolHeartbeat(msg) {
const toolUseId = msg.heartbeat === true ? (msg.parent_tool_use_id ?? msg.tool_use_id) : msg.tool_use_id
return {
kind: 'tool',
...(typeof toolUseId === 'string' ? { toolUseId } : {}),
...(Number.isFinite(msg.elapsed_time_seconds) ? { elapsedMs: msg.elapsed_time_seconds * 1000 } : {}),
}
}

function heartbeatFrames(sessionId, state, now, beat) {
if (!state.heartbeats.admit(beat.kind, now)) return []
return [{ type: 'sdk.heartbeat', sessionId, ...beat }]
}
19 changes: 0 additions & 19 deletions crates/freshell-claude-sidecar/monotonic-clock.mjs

This file was deleted.

28 changes: 8 additions & 20 deletions crates/freshell-claude-sidecar/permission-channel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
// respondPermission :771-783; pending map shapes sdk-bridge-types.ts:113-126).
//
// Pure-ish: state lives on the per-session record; every function takes
// explicit dependencies (emit / nanoid / nextMonotonic) so the module never
// imports index.mjs's sessions Map or module state.
// explicit dependencies (emit / nanoid) so the module never imports index.mjs's
// sessions Map or module state. The waiting edge (0 -> >=1 pending) is the
// server's: it folds the request frames and mints the edge itself (C18).

// Lazily attach the two pending maps (sdk-bridge-types.ts:113-126 shapes).
export function ensurePending(session) {
Expand All @@ -16,15 +17,6 @@ export function ensurePending(session) {
return session
}

// Server-authoritative 0 -> >=1 pending edge (SdkBridge.emitWaitingEdge :515-519).
// Monotonic per session via the injected nextMonotonic, independent clock from
// the turn-complete edge.
function emitWaitingEdge({ session, emit, nextMonotonic, sessionId }) {
const at = nextMonotonic(session.lastWaitingAt, Date.now())
session.lastWaitingAt = at
emit({ type: 'sdk.turn.waiting', sessionId, at })
}

// Provider-originated cancellation (options.signal; fresh-eyes round-2 F1): on
// abort the pending entry is deleted, the cancelled frame clears the card, and
// the parked promise resolves deny — the query is still open in this state, so
Expand All @@ -34,14 +26,13 @@ function subscribeSignal({ signal, clear }) {
if (signal && !signal.aborted) signal.addEventListener('abort', clear, { once: true })
}

export function raisePermissionRequest({ session, emit, nanoid, nextMonotonic, sessionId, toolName, input, options }) {
export function raisePermissionRequest({ session, emit, nanoid, sessionId, toolName, input, options }) {
// Already-aborted provider: nothing to park, no card to raise — deny at once.
if (options?.signal?.aborted) {
return Promise.resolve({ behavior: 'deny', message: 'Aborted by provider' })
}
ensurePending(session)
const requestId = nanoid()
const wasIdle = session.pendingPermissions.size === 0 && session.pendingQuestions.size === 0
return new Promise((resolve) => {
session.pendingPermissions.set(requestId, {
toolName,
Expand Down Expand Up @@ -71,11 +62,10 @@ export function raisePermissionRequest({ session, emit, nanoid, nextMonotonic, s
blockedPath: options?.blockedPath,
decisionReason: options?.decisionReason,
})
if (wasIdle) emitWaitingEdge({ session, emit, nextMonotonic, sessionId })
})
}

export function raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, sessionId, input, signal }) {
export function raiseQuestionRequest({ session, emit, nanoid, sessionId, input, signal }) {
ensurePending(session)
const rawQuestions = input?.questions
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
Expand Down Expand Up @@ -108,7 +98,6 @@ export function raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, ses
return Promise.resolve({ behavior: 'deny', message: 'Aborted by provider' })
}
const requestId = nanoid()
const wasIdle = session.pendingPermissions.size === 0 && session.pendingQuestions.size === 0
return new Promise((resolve) => {
session.pendingQuestions.set(requestId, { originalInput: input, questions, resolve })
subscribeSignal({
Expand All @@ -120,7 +109,6 @@ export function raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, ses
},
})
emit({ type: 'sdk.question.request', sessionId, requestId, questions })
if (wasIdle) emitWaitingEdge({ session, emit, nextMonotonic, sessionId })
})
}

Expand Down Expand Up @@ -174,12 +162,12 @@ export function cancelPending(session, emit, sessionId, { resolveDeny }) {
// canUseTool-shaped adapter (legacy ordering, sdk-bridge.ts:203-214): the
// AskUserQuestion route precedes the bypass fast-path, so a bypass session
// still answers interactive questions.
export function canUseTool({ session, emit, nanoid, nextMonotonic, sessionId, toolName, input, options }) {
export function canUseTool({ session, emit, nanoid, sessionId, toolName, input, options }) {
if (toolName === 'AskUserQuestion') {
return raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, sessionId, input, signal: options?.signal })
return raiseQuestionRequest({ session, emit, nanoid, sessionId, input, signal: options?.signal })
}
if (session.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input }
}
return raisePermissionRequest({ session, emit, nanoid, nextMonotonic, sessionId, toolName, input, options })
return raisePermissionRequest({ session, emit, nanoid, sessionId, toolName, input, options })
}
69 changes: 26 additions & 43 deletions crates/freshell-claude-sidecar/sdk-message-frames.mjs
Original file line number Diff line number Diff line change
@@ -1,49 +1,30 @@
// The pure mapping from an SDK message to the sidecar's stdout frames
// (docs/plans/2026-10-22-freshclaude-core-runtime.md §3.2 and §9.1 N6). One
// function per SDK message kind; index.mjs keeps the side effects (the turn
// gate, pendingResults, the attention clock, emit). The core runtime owns the
// function per SDK message kind; index.mjs keeps the side effects (emit). The
// `result` mapping below; the transcript work owns the content kinds
// (framesForSdkMessage and the functions it dispatches to, at the end).
//
// Result shapes are the pinned CLI's (test/integration/providers/
// claude-cli-contract.test.ts): a Stop ends the turn with
// `error_during_execution`, `terminal_reason: aborted_streaming|aborted_tools`
// and an `[ede_diagnostic] …` error (T11); an API error is a `success` with
// `is_error` (T18), which the transcript shows, so it gets no failure text.

/** terminal_reason values of a turn that was aborted (a Stop landed on it). */
export const ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools'])
// `is_error` (T18). The server reads these facts for its verdict (design §5.3):
// whether the turn rings, and the failure banner it shows (C18). The sidecar
// decides neither.

const DIAGNOSTIC_PREFIX = '[ede_diagnostic]'

const ABORTED_COPY = 'Claude stopped the turn before it finished.'
const SUBTYPE_COPY = {
error_max_turns: 'Claude reached its turn limit. Send a message to continue.',
error_max_budget_usd: 'Claude reached the configured spending limit.',
error_max_structured_output_retries: 'Claude could not produce the requested response format.',
}
const DEFAULT_COPY = 'Claude could not complete this turn. Try sending your message again.'

/** The CLI ended this turn because it was aborted (its terminal_reason says so). */
export function isAbortedResult(result) {
return ABORTED_TERMINAL_REASONS.has(result?.terminal_reason)
}

/**
* The text a failed turn shows the user, or undefined for a successful result.
* The CLI's internal `[ede_diagnostic]` lines are never shown; when nothing
* else is left, the text explains the result kind in plain words.
* The CLI's own error text for a result, one error per line, without its
* internal `[ede_diagnostic]` lines; undefined when none is left.
*/
export function resultErrorText(result) {
if (result.subtype === 'success') return undefined
export function resultErrors(result) {
const errors = Array.isArray(result.errors) ? result.errors : []
const messages = errors
.filter((message) => typeof message === 'string')
.map((message) => message.trim())
.filter((message) => message && !message.startsWith(DIAGNOSTIC_PREFIX))
if (messages.length) return messages.join('\n')
if (isAbortedResult(result)) return ABORTED_COPY
return SUBTYPE_COPY[result.subtype] ?? DEFAULT_COPY
return messages.length ? messages.join('\n') : undefined
}

function resultUsage(usage) {
Expand All @@ -57,23 +38,27 @@ function resultUsage(usage) {
}

/**
* The frames for an SDK `result`: `sdk.result`, then, for a failed turn, the
* `sdk.error{turnFailure}` the client shows as its "Agent error" banner.
* `userStopped` is the turn gate's verdict that this result is the user's own
* Stop: that is not a failure, so it carries no error frame (G03).
* The frames for an SDK `result`: one `sdk.result` carrying the facts the
* server's verdict reads (design §5.3): `subtype` (also as the older
* `result`), `isError`, `terminalReason` and `numTurns` when the CLI reports
* them, and `errorText`, the CLI's own error text without diagnostics
* (resultErrors).
*/
export function resultFrames(sessionId, result, { userStopped }) {
const frames = [{
export function resultFrames(sessionId, result) {
const errorText = resultErrors(result)
return [{
type: 'sdk.result',
sessionId,
result: result.subtype,
subtype: result.subtype,
isError: typeof result.is_error === 'boolean' ? result.is_error : result.subtype !== 'success',
...(typeof result.terminal_reason === 'string' ? { terminalReason: result.terminal_reason } : {}),
...(Number.isInteger(result.num_turns) ? { numTurns: result.num_turns } : {}),
...(errorText !== undefined ? { errorText } : {}),
durationMs: result.duration_ms,
costUsd: result.total_cost_usd,
usage: resultUsage(result.usage),
}]
const failure = userStopped ? undefined : resultErrorText(result)
if (failure) frames.push({ type: 'sdk.error', sessionId, message: failure, turnFailure: true })
return frames
}

// ── content kinds (docs/plans/2026-10-22-freshclaude-transcript-live.md §4.2) ──
Expand Down Expand Up @@ -106,9 +91,8 @@ export function createFrameState() {
/**
* The frames for one SDK message of a content kind (none for any other kind).
* `session` is what the sidecar knows about the session when the message
* arrives: `{ stream, permissionMode, turnPending }` — the session's
* createFrameState(), the permission mode it believes the CLI is in, and
* whether a send still waits for its result.
* arrives: `{ stream, permissionMode }` — the session's createFrameState()
* and the permission mode it believes the CLI is in.
*/
export function framesForSdkMessage(sessionId, msg, session) {
switch (msg?.type) {
Expand Down Expand Up @@ -209,17 +193,16 @@ const COMPACTION_FAILED_COPY = 'Claude could not compact the conversation.'
* is a notice with the CLI's reason (the transcript keeps the same reason as
* a command error); it never ends the turn. Either way, while a send still
* waits for its result the session is running again, not compacting.
* (Interim: core's turn ledger will compute this itself, docs/plans/
* 2026-10-22-freshclaude-core-runtime.md §9.1 N2.)
* The server's turn ledger computes whether the session remains busy from
* the raw status:null fact (lifecycle-frames.mjs).
*/
export function compactionOutcomeFrames(sessionId, msg, { turnPending }) {
export function compactionOutcomeFrames(sessionId, msg) {
if (msg.status !== null || msg.compact_result === undefined) return []
const frames = []
if (msg.compact_result === 'failed') {
const reason = typeof msg.compact_error === 'string' ? msg.compact_error.trim() : ''
frames.push({ type: 'sdk.notice', sessionId, level: 'error', code: 'COMPACTION_FAILED', message: reason || COMPACTION_FAILED_COPY })
}
if (turnPending) frames.push({ type: 'sdk.status', sessionId, status: 'running' })
return frames
}

Expand Down
Loading
Loading