Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d440d9f
feat(engine): attribute an inactive lineage against its own run histo…
Zerlight Sep 6, 2026
7d247b6
feat(client-core): explicit-parent turn.submit, leaf-targeted seeds, …
Zerlight Sep 6, 2026
6a151b3
feat(ui): version navigation, turn state badges, and the parked-linea…
Zerlight Sep 6, 2026
f8c1893
feat(workbench): sibling turns, explicit-parent submits, and leaf rea…
Zerlight Sep 6, 2026
d8d0db9
feat(workbench): browse turn versions and rewrite prompts as siblings…
Zerlight Sep 6, 2026
68cef3b
fix(engine,workbench): announce a failed sibling as a graph shape cha…
Zerlight Sep 6, 2026
f8c1104
fix(engine,schema): unwind a failed relaunch and decide root edits fr…
Zerlight Sep 6, 2026
dfa4c45
fix(engine,client-core): read a failed leaf's shared prefix, announce…
Zerlight Sep 6, 2026
337bab8
fix(workbench,ui): follow the host default after a submit, freeze onl…
Zerlight Sep 6, 2026
1d942a6
fix(workbench): mirror the daemon's settle announcements, pre-dispatc…
Zerlight Sep 6, 2026
9b7e5f9
fix(engine): attribute a failed turn's provider rows and align the li…
Zerlight Sep 7, 2026
ed32870
fix(engine,workbench): guard launches mid-delete, replay responding a…
Zerlight Sep 7, 2026
4b692dd
fix(engine): file a preceding checkpoint under the parent's own run
Zerlight Sep 7, 2026
4952b99
test(client-core): pin the fork re-read against the graph-move shortcut
Zerlight Sep 7, 2026
fdda262
fix(engine): bound live uploads, reap idle stages, and refuse a repla…
Zerlight Sep 7, 2026
8278289
fix(client-core,workbench): pin read pages to the first blob and mirr…
Zerlight Sep 7, 2026
a8c1bb4
fix(engine): count inline images against the harness cap and hand rea…
Zerlight Sep 7, 2026
795f0b3
fix(schema,engine): keep the legacy upload frame open on the wire and…
Zerlight Sep 8, 2026
bcd5805
fix(client-core,schema): refuse an over-long attachment name or MIME …
Zerlight Sep 8, 2026
9176938
fix(workbench): mint no preview URL after a session switch, group sib…
Zerlight Sep 8, 2026
a5f406f
fix(engine): read another version without the active run's live tail
Zerlight Sep 8, 2026
b93989c
fix(engine): read every turn from the history its run wrote, never a …
Zerlight Sep 8, 2026
76e383b
fix(client-core): keep a parked store's session state live so the com…
Zerlight Sep 8, 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
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/src/shell/desktop-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function DesktopShell({
onOpenBilling,
conversation,
onEditPrompt,
lineage,
respondingRequestIds,
responseErrors,
resourcesPanel,
Expand Down Expand Up @@ -465,6 +466,7 @@ export function DesktopShell({
: 'unsupported'
}
onEditPrompt={onEditPrompt}
lineage={lineage}
disabled={!active || active.status === 'stopped'}
isRunning={isRunning}
mentionItems={mentionItems}
Expand Down
14 changes: 14 additions & 0 deletions packages/client/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ Rules the projection store enforces — keep them when touching it:
hid the durable refs. Pending drafts render from the client's blob cache until submit roots the
attachment.
- Live user echoes carry no envelope `turnId` (they precede turn tracking); never bucket by it.
- **A parked view is frozen at its read — its content, not the session's state.** Browsing an
earlier version reads toward that `leafTurnId` (`ConversationSeedSource.leafTurnId`) with
`followLive: false`: the live stream's content belongs to the active lineage's run and must not
fold into another version, and a graph change is the owner's business (the "continued
elsewhere" chip), not a re-read. Session state (`status`, policy, model, effort, mode,
capabilities, commands, models, usage) still folds from the live buffer, latest wins, no
watermark — those events reach a client only live, so a store that skipped them would render the
composer at defaults while parked. The caller decides
`followLive` from the read the store holds — a leaf on, behind, or ahead of the host default
follows; another version is frozen — never from view state, which runs ahead of the read by a
round trip on every switch.
- **Edits and continues are explicit-parent submits** (`submitTurn(…, target)`): the daemon
validates the parent and `expectedGraphRevision` and answers typed `conflict`/`busy`; the
client never calls `history.branch` on a host that serves the graph and can fork.

`history-unavailable` read items become `ConversationItem`s of that kind under the current turn:
the prompt-only fallback for a lost, compacted, or never-recorded transcript.
10 changes: 8 additions & 2 deletions packages/client/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import type {
ConversationReadClientOptions,
HistoryListClientOptions,
HistoryReadClientOptions,
TurnSubmitTarget,
} from './client/control-channel';
import { ControlChannel } from './client/control-channel';
import type { ConversationGraphChange } from './client/conversation-graph-changes';
Expand Down Expand Up @@ -139,6 +140,7 @@ export type {
ConversationReadClientOptions,
HistoryListClientOptions,
HistoryReadClientOptions,
TurnSubmitTarget,
} from './client/control-channel';
export type { ConversationGraphChange } from './client/conversation-graph-changes';
export type { AgentEventEnvelope, SequencedAgentEvent } from './client/event-buffer';
Expand Down Expand Up @@ -848,8 +850,12 @@ export class LinkCodeClient {
}

/** See {@link ControlChannel.submitTurn}. */
submitTurn(sessionId: SessionId, input: TurnSubmitInput): Promise<TurnSubmitResult> {
return this.control.submitTurn(sessionId, input);
submitTurn(
sessionId: SessionId,
input: TurnSubmitInput,
target?: TurnSubmitTarget,
): Promise<TurnSubmitResult> {
return this.control.submitTurn(sessionId, input, target);
}

/** The newest `conversation.graph.changed` seen for the session on this connection. */
Expand Down
20 changes: 18 additions & 2 deletions packages/client/core/src/client/attachment-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
ATTACHMENT_UPLOAD_CHUNK_BYTES,
ATTACHMENT_UPLOAD_WINDOW_CHUNKS,
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENT_NAME_LENGTH,
MAX_MIME_TYPE_LENGTH,
} from '@linkcode/schema';
import type { Transport } from '@linkcode/transport';
import { noop } from 'foxts/noop';
Expand Down Expand Up @@ -48,6 +50,17 @@ export class AttachmentChannel {
) {}

beginUpload(input: AttachmentBeginInput): Promise<AttachmentUploadBegun> {
// The wire schema bounds both fields; an over-long begin is dropped unanswered, never refused.
if (input.name.length > MAX_ATTACHMENT_NAME_LENGTH) {
return Promise.reject(
new Error(`Attachment name exceeds ${MAX_ATTACHMENT_NAME_LENGTH} characters`),
);
}
if (input.mimeType !== undefined && input.mimeType.length > MAX_MIME_TYPE_LENGTH) {
return Promise.reject(
new Error(`Attachment MIME type exceeds ${MAX_MIME_TYPE_LENGTH} characters`),
);
}
return sendCorrelated(this.transport, this.pending, 'attachmentBegin', (clientReqId) => ({
kind: 'attachment.upload.begin',
clientReqId,
Expand Down Expand Up @@ -144,10 +157,13 @@ export class AttachmentChannel {
let offset = 0;
while (offset < first.sizeBytes) {
const slice = base64ToBytes(page.data);
// A page that repeats an offset, returns nothing, or overruns the recorded size cannot be
// assembled — without this the walk never advances and zero-fills what it could not read.
// A page that repeats an offset, returns nothing, overruns the recorded size, or names another
// blob or size cannot be assembled — without this the walk never advances, zero-fills what it
// could not read, or caches spliced bytes under the first page's blob id.
if (
page.offset !== offset ||
page.blobId !== first.blobId ||
page.sizeBytes !== first.sizeBytes ||
slice.byteLength === 0 ||
offset + slice.byteLength > bytes.byteLength
) {
Expand Down
23 changes: 20 additions & 3 deletions packages/client/core/src/client/control-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ export interface ConversationReadClientOptions {
limit?: number;
}

/** Where an explicit-parent submit lands: `null` starts a new root lineage (editing the first
* prompt); a turn id submits a sibling under that turn (edit) or a child of a leaf (continue). */
export interface TurnSubmitTarget {
parentTurnId: TurnId | null;
expectedGraphRevision: number;
}

/**
* Correlated control-plane requests (sessions, history, config, git, workspaces); replies are
* correlated via the shared {@link PendingRegistry} (see {@link sendCorrelated}).
Expand Down Expand Up @@ -186,16 +193,26 @@ export class ControlChannel {
}

/**
* Plain send onto the active leaf. `parentTurnId` is omitted on purpose — explicit-parent
* submit is a later client. Idempotency is a fresh `operationId` per call.
* Without `target`, a plain send onto the active leaf — no parent, no revision, so a device
* racing a turn another device just finished never conflicts on staleness. With `target`, the
* edit/continue path: the daemon validates the parent and the graph revision. Idempotency is a
* fresh `operationId` per call.
*/
submitTurn(sessionId: SessionId, input: TurnSubmitInput): Promise<TurnSubmitResult> {
submitTurn(
sessionId: SessionId,
input: TurnSubmitInput,
target?: TurnSubmitTarget,
): Promise<TurnSubmitResult> {
return this.sendCorrelated('turnSubmit', (clientReqId) => ({
kind: 'turn.submit',
clientReqId,
sessionId,
operationId: OperationIdSchema.parse(`op-${clientReqId}`),
input,
...(target !== undefined && {
parentTurnId: target.parentTurnId,
expectedGraphRevision: target.expectedGraphRevision,
}),
}));
}

Expand Down
8 changes: 5 additions & 3 deletions packages/client/core/src/client/conversation-graph-changes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { SessionId, TurnId } from '@linkcode/schema';
import type { Unsubscribe } from '@linkcode/transport';

/** One `conversation.graph.changed` broadcast: the graph moved its default leaf or gained shape. */
/** One `conversation.graph.changed` broadcast: the graph moved its default leaf or gained shape
* (a new revision), or a turn reached its terminal state (the revision stands). */
export interface ConversationGraphChange {
graphRevision: number;
activeLeafTurnId?: TurnId;
Expand All @@ -12,15 +13,16 @@ type ChangeCb = (change: ConversationGraphChange) => void;
/**
* Per-session register of the newest graph revision the daemon announced on this connection. Not
* a buffer: a store holding a read at an older revision only needs to know that a newer one exists
* and where its leaf is.
* and where its leaf is. Subscribers hear every announcement — a same-revision one carries a turn
* state they must refetch.
*/
export class ConversationGraphChanges {
private readonly latest = new Map<SessionId, ConversationGraphChange>();
private readonly subscribers = new Map<SessionId, Set<ChangeCb>>();

note(sessionId: SessionId, change: ConversationGraphChange): void {
const current = this.latest.get(sessionId);
if (current !== undefined && current.graphRevision >= change.graphRevision) return;
if (current !== undefined && current.graphRevision > change.graphRevision) return;
this.latest.set(sessionId, change);
const subs = this.subscribers.get(sessionId);
if (subs) for (const cb of subs) cb(change);
Expand Down
9 changes: 8 additions & 1 deletion packages/client/core/src/conversation-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export interface ConversationSeedSource {
agentKind: AgentKind;
cwd: string;
historyId?: AgentHistoryId;
/** Read toward this leaf instead of the session's active one — a client browsing an earlier
* version. Only the projection path knows lineages; the transcript fallback ignores it. */
leafTurnId?: TurnId;
}

/** Transcript pages one history read follows before giving up on a buggy cursor. */
Expand All @@ -118,7 +121,11 @@ export async function readConversationSeed(
source: ConversationSeedSource,
): Promise<ConversationProjectionSeed | ConversationSeed | undefined> {
if (client.supportsConversationGraph) {
const projection = await readConversationProjection(client, source.sessionId);
const projection = await readConversationProjection(
client,
source.sessionId,
source.leafTurnId === undefined ? {} : { leafTurnId: source.leafTurnId },
);
if (projection !== undefined) return projection;
}
if (source.historyId === undefined) return undefined;
Expand Down
41 changes: 38 additions & 3 deletions packages/client/core/src/conversation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export type ConversationResyncReason = 'epoch' | 'gap' | 'graph';
export interface ConversationStoreOptions {
/** Called at most once per store, never during a render, when the seed must be re-read. */
onResync?: (reason: ConversationResyncReason) => void;
/** `false` freezes a projection store's content at its read: a client browsing an inactive
* lineage must not fold the active run's live stream, and a graph change is the owner's business
* (the "continued elsewhere" chip), not a re-read. Session state — policy, model, effort, mode,
* capabilities, commands, usage, status — still follows: it is the session's, not a lineage's,
* and the composer renders it. Default `true`. */
followLive?: boolean;
}

const EMPTY_CONVERSATION: Conversation = {
Expand Down Expand Up @@ -58,7 +64,13 @@ export function createConversationStore(
return { subscribe: () => noop, getSnapshot: () => EMPTY_CONVERSATION };
}
if (seed !== undefined && 'items' in seed) {
return createProjectionStore(client, sessionId, seed, options.onResync ?? noop);
return createProjectionStore(
client,
sessionId,
seed,
options.onResync ?? noop,
options.followLive ?? true,
);
}
return createHistoryStore(client, sessionId, seed, options.onResync ?? noop);
}
Expand All @@ -74,6 +86,21 @@ const INTERACTIVE_EVENT_TYPES = new Set<AgentEvent['type']>([
'prompt-response-status',
]);

/** Session state, not lineage content: the latest of each wins, so a frozen store folds them
* without a watermark — a parked composer must not fall back to defaults. */
const SESSION_STATE_EVENT_TYPES = new Set<AgentEvent['type']>([
'status',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'status' isn't purely session state — it moves the parked view's content too, which is the one thing the comment above says a frozen store won't do.

case 'status' (conversation.ts:583-591) also writes turnStopped, and snapshot() derives isSessionStreaming = !turnStopped && (status === 'running' || status === 'starting') (conversation.ts:758-768), overlaying isStreaming: true on the parked read's last assistant message and on any open reasoning item. Downstream, conversation-view.tsx:101 derives isThinking from conversation.status and feeds it to both the trailing <Spinner/> thinking… element and ended={index < segments.length - 1 || !isThinking}.

So while the active lineage is running, a parked ‹ 1/N › view of a settled version renders a "thinking…" spinner after its last turn, suppresses that turn's trailers (diff rollup / copy / reply actions), and re-animates its final assistant message through smoothText (turn-segment-view.tsx:175-188) — none of which belongs to that version.

Still a net improvement over a composer frozen at defaults, and status can't simply leave the set: it's what drives send-vs-stop and promptEditState: 'busy'. But if a parked view should read as settled, the seam is between the scalar and the derived overlay — pin isSessionStreaming to false on a parked snapshot (or fold status into the scalar without letting it clear turnStopped) and the composer stays live either way.

'current-mode-update',
'approval-policy-update',
'model-update',
'effort-update',
'available-commands-update',
'available-models-update',
'capabilities-update',
'token-usage',
'usage-report',
]);

/**
* The projection merge: the seed's items fold first, then every live event whose position is
* above the seed's watermark. Nothing is matched by content — the daemon mints one identity per
Expand All @@ -86,6 +113,7 @@ function createProjectionStore(
sessionId: SessionId,
seed: ConversationProjectionSeed,
onResync: (reason: ConversationResyncReason) => void,
followLive: boolean,
): ConversationStore {
const builder = createConversationBuilder();
const userMessageIds = new Set<string>();
Expand Down Expand Up @@ -145,14 +173,19 @@ function createProjectionStore(
const events = client.eventsSnapshot(sessionId);
for (let i = firstIndexAfter(events, consumedSeq), len = events.length; i < len; i += 1) {
const entry = events[i];
if (!followLive) {
if (SESSION_STATE_EVENT_TYPES.has(entry.event.type)) fold(entry.event, entry.receivedAt);
continue;
}
if (admit(entry)) fold(entry.event, entry.receivedAt);
}
consumedSeq = client.eventSeq(sessionId);
};

/** A revision past this read means a lineage moved. A plain continuation is already covered
* live — its new leaf's own user row has arrived — so only a leaf this store has never seen
* (an edit or rewrite from any device, a stale read) needs the re-read. */
* (an edit or rewrite from any device, a stale read) needs the re-read. A fork's row cannot
* pass: it relaunches under a new epoch, which `admit` flags first, and reads carry no echoes. */
const checkGraph = (change: ConversationGraphChange | undefined): void => {
if (change === undefined || change.graphRevision <= seed.graphRevision) return;
if (
Expand All @@ -167,11 +200,13 @@ function createProjectionStore(
return {
subscribe(onStoreChange) {
sync();
checkGraph(client.latestGraphChange(sessionId));
const unsubscribeEvents = client.subscribe(sessionId, () => {
sync();
onStoreChange();
});
// A frozen store keeps its session state live but leaves graph changes to its owner.
if (!followLive) return unsubscribeEvents;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subscription half of this fix has no test coverage — including from the test updated alongside it.

I restored the old if (!followLive) return noop; on this line, so a frozen store subscribes to nothing at all, and re-ran conversation-store-projection.test.ts: all 17 tests pass, including the renamed 'freezes a parked read’s content … but not the session’s state'. Control: neutering the fold branch above (lines 176-179 → a bare continue;) fails exactly 1 test — AssertionError: expected null to be 'claude-fable-5' at the new currentModel assertion. So the new case pins the fold, not the notification.

The blind spot is getSnapshot() calling sync() lazily: the test drives the store directly, so every read re-syncs whether or not a subscription exists. Under React, useSyncExternalStore re-reads only when the subscriber fires — without this line's change the parked composer would keep rendering its seeded defaults until some unrelated re-render happened to flush it, which is precisely the bug the commit set out to fix. Asserting that the store's own subscribe callback fires on a session-state event while parked would pin it.

Behaviour looks right as written; it's just load-bearing and currently free to regress.

checkGraph(client.latestGraphChange(sessionId));
const unsubscribeGraph = client.subscribeGraphChanges(sessionId, (change) => {
sync();
checkGraph(change);
Expand Down
5 changes: 3 additions & 2 deletions packages/client/core/src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,13 @@ export function useConversation(
sessionId: SessionId | null,
seed?: ConversationSeed | ConversationProjectionSeed,
onResync?: (reason: ConversationResyncReason) => void,
followLive = true,
): Conversation {
const client = useLinkCodeClient();
const handleResync = useStableHandler(onResync ?? noop);
const store = useMemo(
() => createConversationStore(client, sessionId, seed, { onResync: handleResync }),
[client, sessionId, seed, handleResync],
() => createConversationStore(client, sessionId, seed, { onResync: handleResync, followLive }),
[client, sessionId, seed, handleResync, followLive],
);
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}
Expand Down
62 changes: 62 additions & 0 deletions packages/client/core/tests/integration/attachment-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
AttachmentIdSchema,
BlobIdSchema,
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENT_NAME_LENGTH,
MAX_MIME_TYPE_LENGTH,
SessionIdSchema,
UploadIdSchema,
} from '@linkcode/schema';
Expand Down Expand Up @@ -259,6 +261,39 @@ describe('LinkCodeClient attachment store API', () => {
serverTransport.close();
});

it('fails the read walk when a later page names another blob', async () => {
const { client, serverTransport } = await createConnectedLocalClient();
const bytes = new Uint8Array(ATTACHMENT_UPLOAD_CHUNK_BYTES + 8).fill(5);
const attachmentId = AttachmentIdSchema.parse('att-5');
const sessionId = SessionIdSchema.parse('session-1');

serverTransport.onMessage((message) => {
const p = message.payload;
if (p.kind !== 'attachment.read') return;
const slice = bytes.subarray(p.offset, p.offset + p.length);
serverTransport.send(
createWireMessage({
kind: 'attachment.read.result',
replyTo: p.clientReqId,
sessionId: p.sessionId,
attachmentId: p.attachmentId,
// The second page answers from another record: its bytes must not be spliced in and
// cached under the first page's blob.
blobId: BlobIdSchema.parse(`sha256:${(p.offset === 0 ? 'f' : '0').repeat(64)}`),
offset: p.offset,
data: bytesToBase64(slice),
sizeBytes: bytes.byteLength,
eof: p.offset + slice.byteLength >= bytes.byteLength,
}),
);
});

await expect(client.getAttachmentBytes(sessionId, attachmentId)).rejects.toThrow('att-5');

client.dispose();
serverTransport.close();
});

it('aborts the upload when a chunk is rejected', async () => {
const { client, serverTransport } = await createConnectedLocalClient();
const bytes = new Uint8Array(ATTACHMENT_UPLOAD_CHUNK_BYTES * 2).fill(5);
Expand Down Expand Up @@ -343,6 +378,33 @@ describe('LinkCodeClient attachment guards', () => {
serverTransport.close();
});

it('rejects an over-long name or MIME type before any frame leaves the client', async () => {
const { client, serverTransport } = await createConnectedLocalClient();
const seen: string[] = [];
serverTransport.onMessage((message) => {
seen.push(message.payload.kind);
});
const bytes = new Uint8Array(4);
await expect(
client.putAttachment({
bytes,
name: 'n'.repeat(MAX_ATTACHMENT_NAME_LENGTH + 1),
attachmentKind: 'file',
}),
).rejects.toThrow('name exceeds');
await expect(
client.putAttachment({
bytes,
name: 'ok.bin',
mimeType: `text/${'x'.repeat(MAX_MIME_TYPE_LENGTH)}`,
attachmentKind: 'file',
}),
).rejects.toThrow('MIME type exceeds');
expect(seen).not.toContain('attachment.upload.begin');
client.dispose();
serverTransport.close();
});

it('fails typed against a peer without the attachment store instead of hanging', async () => {
const [clientTransport, serverTransport] = createLocalTransportPair();
await serverTransport.connect();
Expand Down
Loading
Loading