Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f8b8382
refactor(claude-sidecar): the SDK content frames are a pure mapping (B1)
danshapiro Oct 23, 2026
1ea78d0
feat(claude-sidecar): announce each transcript message; sdk.assistant…
danshapiro Oct 23, 2026
257d523
feat(claude-sidecar): stream only what a live pane renders, stamped w…
danshapiro Oct 23, 2026
24026e9
feat(claude-sidecar): API retries, rate limits and informational bann…
danshapiro Oct 23, 2026
7eb80b4
feat(claude-sidecar): a compaction's outcome and the CLI's own permis…
danshapiro Oct 23, 2026
ac5ba59
test(claude): real-CLI contract for the sidecar's live frames (B6)
danshapiro Oct 23, 2026
726e2d1
feat(claude): API retries and notices reach every client (C1)
danshapiro Oct 23, 2026
9844a66
feat(claude): the CLI's own permission-mode change becomes the sessio…
danshapiro Oct 23, 2026
e7cb393
feat(claude): settings metadata states the permission mode (C2)
danshapiro Oct 23, 2026
0d17b6a
test(claude-sim): replay recorded real-CLI turns; scripted API retries
danshapiro Oct 23, 2026
9c50b92
fix(claude): snapshots read the transcript store the lane's sessions …
danshapiro Oct 23, 2026
397f1a5
test(claude): live rendering over the real sidecar, end to end (C3)
danshapiro Oct 23, 2026
e105bbc
perf(claude): snapshots project only the transcript lines appended si…
danshapiro Oct 23, 2026
3659252
refactor(store): name the app's reducer map in one module
danshapiro Oct 23, 2026
5e9e539
feat(fresh-agent): stream deltas feed a live slice, never the session…
danshapiro Oct 23, 2026
7681f4e
test(fresh-agent): app-wide chrome commits nothing across a token bur…
danshapiro Oct 23, 2026
5d4503a
fix(fresh-agent): an exit for a session this client never saw is ignored
danshapiro Oct 23, 2026
8333e7e
feat(fresh-agent): the live tail binds, lands and clears with its ses…
danshapiro Oct 23, 2026
7dbabda
feat(fresh-agent): a live tail renders the running turn like the turn…
danshapiro Oct 23, 2026
4e29337
feat(fresh-agent): panes show the live tail until its lines land (D5)
danshapiro Oct 23, 2026
e60c712
fix(fresh-agent): the transcript hands its live reel to the tail with…
danshapiro Oct 23, 2026
0d0f429
feat(fresh-agent): provider notices show in the pane banner without e…
danshapiro Oct 23, 2026
a318e9d
feat(fresh-agent): the pane follows the permission mode the session s…
danshapiro Oct 23, 2026
b870e70
fix(fresh-agent): a turn's cost replaces the session total instead of…
danshapiro Oct 23, 2026
cda363e
fix(fresh-agent): a streamed block whose line uuid never arrived stil…
danshapiro Oct 23, 2026
a48e480
test(fresh-agent): a streamed answer is on screen exactly once, frame…
danshapiro Oct 23, 2026
2160e33
test(fresh-agent): the live tail lands a real CLI turn by uuid and by…
danshapiro Oct 23, 2026
a3e6016
perf(fresh-agent): a background pane does not render its live tail
danshapiro Oct 23, 2026
2fc7b71
refactor(fresh-agent): reflow the landing hook's description
danshapiro Oct 23, 2026
07f9aaf
test(claude-sim): a replayed turn can change the session's files as t…
danshapiro Oct 23, 2026
f403e63
test(e2e): freshclaude and kilroy live transcript over the production…
danshapiro Oct 23, 2026
3037cba
docs(ui): the mock shows a freshclaude turn rendering live (E3)
danshapiro Oct 23, 2026
5f63fd2
test(claude-sim): a tool step can stream the model's input for a while
danshapiro Oct 23, 2026
8e669b6
test(e2e): a tool row shows as soon as the model starts the call
danshapiro Oct 23, 2026
c441a06
test(claude): the live-frames fake leaves no sidecar or store path be…
danshapiro Oct 23, 2026
3b84534
test(fresh-agent): a subagent's stream events never reach the live tail
danshapiro Oct 23, 2026
82d7560
fix(claude-sidecar): the CLI's quiet informational messages raise no …
danshapiro Oct 23, 2026
ab3c614
fix(fresh-agent): a permission mode the pane cannot show releases the…
danshapiro Oct 23, 2026
bcd9db9
fix(fresh-agent): an error keeps the live tail's unlanded text; a tai…
danshapiro Oct 23, 2026
cc8cfbd
fix(fresh-agent): a finished answer stays above the next prompt until…
danshapiro Oct 23, 2026
cef0970
perf(fresh-agent): a snapshot refetch re-renders only the turns that …
danshapiro Oct 23, 2026
45f177c
test(claude-sim): an approved ExitPlanMode returns to the mode before…
danshapiro Oct 23, 2026
78910cf
test(claude-sim): API retries are held to the real CLI by a shared RE…
danshapiro Oct 23, 2026
83704ed
Merge origin/main into feat/freshclaude-live-transcript
danshapiro Oct 23, 2026
f11cbd0
fix(fresh-agent): an API retry shows the turn running before anything…
danshapiro Oct 23, 2026
0d20caf
fix(fresh-agent): a just-created session shows its first turn running
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
85 changes: 50 additions & 35 deletions crates/freshell-claude-sidecar/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,23 @@
// turn), and the sidecar then exits with code 2.
// { type:'sdk.configured', sessionId, requestId, ok, settings?, message? }
// { type:'sdk.session.init', sessionId, cliSessionId, model, cwd, tools } // durable Claude UUID
// { type:'sdk.assistant', sessionId, content, model }
// { type:'sdk.stream', sessionId, event, parentToolUseId }
// { type:'sdk.assistant', sessionId, content, model, uuid, messageId, parentToolUseId }
// — one per completed content block (blocks of one API message share
// messageId); parentToolUseId is set inside a subagent, else null
// { type:'sdk.stream', sessionId, event, parentToolUseId:null, messageId }
// — the main thread's message_start, content_block_start/stop and
// text/thinking deltas only, stamped with the API message id of the
// message being streamed (a subagent's events, tool input JSON,
// signatures, message_delta and message_stop are not sent)
// { type:'sdk.retry', sessionId, attempt, maxRetries, delayMs, errorStatus, error }
// — the CLI will retry a failed API request (errorStatus null: no HTTP
// response); live only, never a turn end
// { type:'sdk.notice', sessionId, level:'info'|'warning'|'error', code, message, rateLimit? }
// — a non-terminal provider notice: RATE_LIMIT_WARNING / RATE_LIMITED
// (rateLimit {status, resetsAt?, rateLimitType?, utilization?}),
// INFORMATIONAL (the CLI's own warning or suggestion text, or a message
// that stops the turn; its quiet info/notice levels are not sent),
// COMPACTION_FAILED (the CLI's reason); never a turn end
// { type:'sdk.result', sessionId, result, durationMs, costUsd, usage }
// { type:'sdk.turn.complete', sessionId, at } // EVERY result (any subtype) except
// the stopped turn's own aborted result (an accepted user interrupt's mark
Expand All @@ -51,8 +66,16 @@
// — the command catalog without the terminal-only commands (the pinned
// CLI's until a turn's init names its own): from the boot, then
// whenever a turn's init or commands_changed changes it
// { type:'sdk.status', sessionId, status } // compacting (an ended stream sends
// no status: the server reports the session as exited)
// { type:'sdk.session.changed', sessionId, reason:'transcript', uuid, parentToolUseId }
// — the CLI writes this message (assistant, user, compaction boundary,
// local command output) to its transcript under `uuid`, about 100 ms
// later; sent right after the message's own frames
// (sdk-message-frames.mjs)
// { type:'sdk.status', sessionId, status } // compacting; running when a
// compaction ends while a send still waits for its result (interim, core
// N2); an ended stream sends no status: the server reports it as exited
// { type:'sdk.session.metadata', sessionId, permissionMode } // the CLI changed its own
// permission mode (an ExitPlanMode approval); the session adopts it first
// { type:'sdk.error', sessionId, message, turnFailure? } // turnFailure: a failed turn's result
// (sanitized text, no [ede_diagnostic] lines); never for the user's own Stop
// { type:'sdk.stream.ended', sessionId, reason, message } // the session's SDK stream ended
Expand Down Expand Up @@ -103,7 +126,7 @@
import { createInterface } from 'node:readline'
import { randomBytes } from 'node:crypto'
import { configureSession, userMessageContent } from './session-settings.mjs'
import { resultFrames } from './sdk-message-frames.mjs'
import { createFrameState, frameLogRecord, framesForSdkMessage, resultFrames } from './sdk-message-frames.mjs'
import { createTurnCompleteGate } from './turn-complete-gate.mjs'
import { checkpointAttemptOf, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs'
import { nextMonotonic } from './monotonic-clock.mjs'
Expand Down Expand Up @@ -323,34 +346,9 @@ function handleSdkMessage(sessionId, msg) {
st.commandCatalog = commands
publishCommandsIfChanged(sessionId, st)
}
} else if (msg.subtype === 'status' && msg.status === 'compacting') {
emit({ type: 'sdk.status', sessionId, status: 'compacting' })
} else if (msg.subtype === 'compact_boundary') {
// kata 1wxv ep3-r1 F1: the bare compacting STATUS frame carries no
// trigger — the SDK fires it for an explicit `/compact` AND for its
// own automatic context compaction, and misattributing the automatic
// one to a queued explicit compact wedges the rollback busy gate
// (the phantom compact absorbs the turn's own terminal edge). Only the
// compact COMPLETION boundary discriminates the trigger; relay it.
// Fail toward 'auto' on a missing/unknown trigger so promotion to a
// queued explicit compact never happens without a proven manual run.
const trigger = msg.compact_metadata?.trigger === 'manual' ? 'manual' : 'auto'
emit({ type: 'sdk.compact_boundary', sessionId, trigger })
}
break
}
case 'assistant': {
const content = msg.message?.content || []
const blocks = content.map((b) => {
if (b.type === 'text') return { type: 'text', text: b.text }
if (b.type === 'thinking') return { type: 'thinking', thinking: b.thinking }
if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input }
if (b.type === 'tool_result') return { type: 'tool_result', tool_use_id: b.tool_use_id, content: b.content, is_error: b.is_error }
return b
})
emit({ type: 'sdk.assistant', sessionId, content: blocks, model: msg.message?.model })
break
}
case 'result': {
// The awaited turn's terminal frame arrived: drop one pending send
// (clamped at 0 — an unsolicited result must not underflow the counter).
Expand All @@ -369,14 +367,29 @@ function handleSdkMessage(sessionId, msg) {
}
break
}
case 'stream_event': {
emit({ type: 'sdk.stream', sessionId, event: msg.event, parentToolUseId: msg.parent_tool_use_id })
break
}
default:
// Unhandled SDK message type — ignored (matches SdkBridge default).
break
}
// The content kinds (assistant, user, stream events, status, retries and
// notices): a pure mapping, sdk-message-frames.mjs. Any kind it does not
// map is dropped there.
const frames = framesForSdkMessage(sessionId, msg, {
stream: st.frameState,
permissionMode: st.settings.permissionMode,
turnPending: st.pendingResults > 0,
})
for (const frame of frames) {
// The CLI changed its own permission mode (an ExitPlanMode approval): the
// session adopts it before saying so, so a configure to that mode applies
// nothing and the permission channel sees the mode the CLI is in.
if (frame.type === 'sdk.session.metadata' && frame.permissionMode !== undefined) {
st.settings.permissionMode = frame.permissionMode
st.permissionMode = frame.permissionMode
}
emit(frame)
const record = frameLogRecord(frame)
if (record) log(record.severity, record.event, record.fields)
}
}

async function consumeStream(sessionId, sdkQuery) {
Expand Down Expand Up @@ -508,6 +521,8 @@ function handleCreate(req) {
turnCompleteGate: createTurnCompleteGate(),
pendingResults: 0,
pendingCheckpointAttempts: new Map(),
// The content mapping's own per-session state (sdk-message-frames.mjs).
frameState: createFrameState(),
}
const { iterable, handle } = createInputStream((isCompact) => {
if (isCompact) state.handedCompactLikely = true
Expand Down
Loading
Loading