@canonmsg/claude-code-plugin 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,18 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk';
3
+ import { DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isCanonMessageIdConflict, isRetryableCanonDeliveryError, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
4
+ import { boundTrailBlockMap, buildTrailBlockId, buildUndeliverableFinalNotice as buildHostUndeliverableFinalNotice, normalizePlanStepStatus, normalizeTrailKey, PLAN_BLOCK_TITLE, renderPlanSteps, truncateFailureDetail, } from '@canonmsg/coding-agent-host';
3
5
  export function createClaudeInputEnvelope(input) {
4
6
  const sourceMessageId = input.sourceMessageId ?? null;
5
7
  const fallbackId = randomUUID();
8
+ // Stamped so each turn result can be matched back to the envelope that caused
9
+ // it via SDKResultMessage.user_message_uuid, instead of inferring ownership
10
+ // from a mutable slot and arrival order.
11
+ const messageUuid = randomUUID();
6
12
  return {
7
13
  kind: input.kind,
8
- msg: input.msg,
14
+ messageUuid,
15
+ msg: { ...input.msg, uuid: messageUuid },
9
16
  intent: input.intent ?? 'queue',
10
17
  sourceMessageId,
11
18
  markAccepted: Boolean(input.markAccepted),
@@ -49,8 +56,61 @@ export function shouldDeliverClaudeFinal(input) {
49
56
  return false;
50
57
  return true;
51
58
  }
59
+ /** Bounds the correlation map if the SDK ever stops reporting a uuid we sent. */
60
+ const MAX_TRACKED_DISPATCHED_INPUTS = 64;
61
+ export function rememberDispatchedClaudeInput(dispatched, input) {
62
+ dispatched.set(input.messageUuid, input);
63
+ while (dispatched.size > MAX_TRACKED_DISPATCHED_INPUTS) {
64
+ const oldest = dispatched.keys().next();
65
+ if (oldest.done)
66
+ break;
67
+ dispatched.delete(oldest.value);
68
+ }
69
+ }
70
+ /**
71
+ * The envelope a turn result belongs to, matched by `user_message_uuid`.
72
+ *
73
+ * The SDK coalesces queued inputs — three rapid messages can produce two turns —
74
+ * and reports the LAST message of a coalesced batch, which is the correct owner
75
+ * of the reply. Entries up to and including the match are consumed, so messages
76
+ * folded into that batch do not linger. Returns null when the result carries no
77
+ * usable uuid, leaving the caller to fall back.
78
+ */
79
+ export function takeClaudeResultOwner(dispatched, userMessageUuid) {
80
+ if (typeof userMessageUuid !== 'string' || !dispatched.has(userMessageUuid)) {
81
+ return null;
82
+ }
83
+ let owner = null;
84
+ for (const [key, envelope] of dispatched) {
85
+ dispatched.delete(key);
86
+ if (key === userMessageUuid) {
87
+ owner = envelope;
88
+ break;
89
+ }
90
+ }
91
+ return owner;
92
+ }
93
+ /**
94
+ * Whether an envelope owns the session's active-turn slot. Only Canon turns do;
95
+ * any future internal envelope must remain invisible to user-facing turn state.
96
+ */
97
+ export function claudeInputOwnsTurnSlot(input) {
98
+ return input?.kind === 'canon';
99
+ }
100
+ /** Why a non-empty final was gated instead of sent. Diagnostics only. */
101
+ export function describeUndeliveredClaudeFinal(input) {
102
+ if (!input.turn)
103
+ return 'no Canon turn owns this result';
104
+ if (input.turn.kind !== 'canon')
105
+ return `owning turn is '${input.turn.kind}'`;
106
+ if (input.interruptedTurnKeys.has(input.turn.turnKey))
107
+ return 'turn was interrupted';
108
+ if (input.finalizedTurnKeys.has(input.turn.turnKey))
109
+ return 'turn was already finalized';
110
+ return 'turn did not qualify for delivery';
111
+ }
52
112
  export function resetClaudeCompletedTurnState(session) {
53
- session.state.state = 'idle';
113
+ releaseClaudeTurnSlot(session);
54
114
  session.currentTurnId = null;
55
115
  session.currentTurnOpenedAt = null;
56
116
  session.currentTurnUpdatedAt = null;
@@ -58,8 +118,575 @@ export function resetClaudeCompletedTurnState(session) {
58
118
  session.pendingFinalText = null;
59
119
  session.pendingFinalDelivery = null;
60
120
  session.activeInput = null;
121
+ session.dispatchingInput = null;
61
122
  session.toolInProgress = false;
62
123
  session.turnState = 'idle';
124
+ // The trail bookkeeping only ever describes blocks of the turn that just
125
+ // ended; a tool_use id surviving into the next turn would resolve to a block
126
+ // that no longer exists.
127
+ if (session.turnActivity)
128
+ resetClaudeTurnActivityState(session.turnActivity);
129
+ }
130
+ /**
131
+ * Whether the session already owns the turn slot.
132
+ *
133
+ * 'running' covers both a turn the SDK is working on and one still being
134
+ * dispatched — the distinction matters only to an interrupt (below), never to
135
+ * whether the session is free to start something else. Deliberately NOT a new
136
+ * published state: `session.state.state` is the host's internal busy flag (the
137
+ * published session snapshot has no such field), and the user-facing signal is
138
+ * the turn state, which `openTurn` already publishes as 'thinking' at dequeue.
139
+ *
140
+ * 'requires_action' counts too: an approval or a question waiting on a human is
141
+ * a turn that is very much alive, holding the turn id and the live node. Reading
142
+ * it as free let a message arriving during the prompt open a SECOND turn over
143
+ * the waiting one — the same collision the reservation exists to stop, and
144
+ * inconsistent with every interrupt path, which already treats it as active.
145
+ */
146
+ export function isClaudeTurnSlotReserved(state) {
147
+ return state === 'running' || state === 'requires_action';
148
+ }
149
+ /**
150
+ * Whether the SDK's `session_state_changed` echo may overwrite the host's own
151
+ * busy flag.
152
+ *
153
+ * The echo and the reservation share one field, and the echo lags: the 'idle'
154
+ * that closes turn A is emitted after A's result has flushed, and the host
155
+ * drains its queue INSIDE that result handler — so by the time the echo is
156
+ * processed, turn B may already hold the slot. Applying it there releases a
157
+ * slot that is taken, re-opening the dispatch-window race and killing the
158
+ * typing signal B just started. An 'idle' echo is therefore ignored while the
159
+ * host knows it is busy; every other echo is authoritative, so the SDK can
160
+ * still take the session busy on its own (a resumed session, an internal
161
+ * envelope) and nothing can wedge the flag at 'running'.
162
+ */
163
+ export function shouldApplyClaudeEchoedSessionState(input) {
164
+ if (input.echoed !== 'idle')
165
+ return true;
166
+ return !input.dispatching && !input.hasPendingFinalDelivery;
167
+ }
168
+ /** Claim the slot for an input about to be dispatched. Must precede any await. */
169
+ export function reserveClaudeTurnSlot(session) {
170
+ session.state.state = 'running';
171
+ }
172
+ /** Give the slot back — dispatch failed, or the turn is over. */
173
+ export function releaseClaudeTurnSlot(session) {
174
+ session.state.state = 'idle';
175
+ }
176
+ /**
177
+ * What to do with a stop signal from the control channel.
178
+ *
179
+ * The dispatch window is the interesting case. The slot is reserved but the
180
+ * input has not reached Claude, so there is nothing for `query.interrupt()` to
181
+ * stop — yet consuming the signal as stale would make a Stop pressed in that
182
+ * window do NOTHING while the turn it was aimed at ran to completion, silently.
183
+ * Deferring hands it back to the poller, which revisits the same node a cycle
184
+ * later, by which time the input really is in flight.
185
+ *
186
+ * Outside that window a signal with nothing to act on is consumed, except that
187
+ * 'stop_and_drop' still has work to do while inputs are queued: dropping them.
188
+ */
189
+ export function decideClaudeControlSignalAction(input) {
190
+ if (input.dispatching)
191
+ return 'defer';
192
+ if (isClaudeTurnSlotReserved(input.sessionState))
193
+ return 'act';
194
+ return input.type === 'stop_and_drop' && input.queueDepth > 0 ? 'act' : 'consume';
195
+ }
196
+ /**
197
+ * What to do with an inbound Canon message.
198
+ *
199
+ * `hasInterruptibleInput` is the one piece that is not just "is the session
200
+ * busy": an interrupt can only stop an input the SDK has actually pulled. In
201
+ * the dispatch window there is nothing in flight to interrupt — calling
202
+ * `query.interrupt()` there would fire at an idle SDK and then let the input it
203
+ * was meant to pre-empt reach the SDK a moment later, unaffected, while the
204
+ * interrupting message sat in the queue behind it. Such a message goes to the
205
+ * FRONT of the queue instead, and starts as soon as the dispatched turn ends.
206
+ *
207
+ * A session waiting on an approval ('requires_action') is busy like any other
208
+ * live turn: an ordinary message queues behind the prompt, and an interrupt
209
+ * stops the waiting turn rather than racing a second one alongside it.
210
+ */
211
+ export function decideClaudeInboundDispatch(input) {
212
+ // A final still being handed to Canon owns the turn until it settles, even
213
+ // though the SDK itself is done.
214
+ if (input.hasPendingFinalDelivery)
215
+ return 'queue';
216
+ if (!isClaudeTurnSlotReserved(input.sessionState))
217
+ return 'start';
218
+ if (input.intent !== 'interrupt')
219
+ return 'queue';
220
+ return input.hasInterruptibleInput ? 'interrupt' : 'queue-front';
221
+ }
222
+ /**
223
+ * Whether a turn that has just finished may hand the slot back.
224
+ *
225
+ * Finishing spans awaits — the final send, the context-usage read — and the
226
+ * session reads free to inbound messages from the SDK's idle echo onward, so a
227
+ * message arriving in that window can reserve the slot for ITSELF. Releasing
228
+ * unconditionally afterwards would hand the same slot to a second message and
229
+ * put two turns into the SDK at once, which is exactly what the reservation
230
+ * exists to stop.
231
+ */
232
+ export function shouldReleaseClaudeTurnSlot(input) {
233
+ if (input.dispatching)
234
+ return false;
235
+ return input.activeTurnKey === null || input.activeTurnKey === input.completedTurnKey;
236
+ }
237
+ /** Whether the next queued input may be dequeued now. */
238
+ export function canDrainClaudeQueuedInput(input) {
239
+ return !input.closed
240
+ && !input.controlInterruptPending
241
+ && !isClaudeTurnSlotReserved(input.sessionState)
242
+ && input.queueDepth > 0;
243
+ }
244
+ /**
245
+ * Dispatch one input: reserve the slot, publish the turn, then do the slow
246
+ * work, then hand it to the SDK.
247
+ *
248
+ * The ORDER is the point, which is why this lives here rather than inline in
249
+ * the host: the reservation and `openTurn` must both happen before the first
250
+ * await. Marking the message accepted is a network round-trip and the artifact
251
+ * baseline walks the workspace; leaving the session readable as free across
252
+ * that window let a second message bypass the queue and open a competing turn
253
+ * over this one (`seedTurnStreaming` wiping the first turn's live node, two
254
+ * inputs in the SDK under mismatched attribution).
255
+ *
256
+ * Never rejects. An input that cannot be handed over — a throw in the window,
257
+ * or a session torn down mid-dispatch — is ABANDONED rather than dropped: the
258
+ * host settles the message and retires the turn, instead of leaving the sender
259
+ * on 'accepted' forever with a 'thinking' row nothing will complete.
260
+ */
261
+ export async function dispatchClaudeInput(session, input, deps) {
262
+ reserveClaudeTurnSlot(session);
263
+ session.dispatchingInput = input;
264
+ deps.openTurn(input);
265
+ let failure = null;
266
+ try {
267
+ await deps.markAccepted(input);
268
+ await deps.prepareArtifacts(input);
269
+ }
270
+ catch (error) {
271
+ failure = { error };
272
+ }
273
+ if (session.dispatchingInput === input)
274
+ session.dispatchingInput = null;
275
+ if (failure) {
276
+ deps.abandon(input, 'error', failure.error);
277
+ return;
278
+ }
279
+ if (session.closed) {
280
+ deps.abandon(input, 'closed');
281
+ return;
282
+ }
283
+ deps.send(input);
284
+ }
285
+ // ── Turn lifecycle: opening a turn before the SDK says anything ──────
286
+ //
287
+ // The host used to publish turn state only when the SDK echoed
288
+ // `session_state_changed: running`, so everything between "message accepted"
289
+ // and "first token" was silent — no thinking header, no dots. Opening is now
290
+ // the host's own decision, taken synchronously at dequeue; these helpers hold
291
+ // the part of that decision that is pure state.
292
+ /** States in which a turn is live for the clients (mirrors the turn-state write). */
293
+ export function isOpenClaudeTurnState(state) {
294
+ return state === 'thinking'
295
+ || state === 'streaming'
296
+ || state === 'tool'
297
+ || state === 'waiting_input';
298
+ }
299
+ /**
300
+ * Whether the SDK's `running` confirmation should open the turn itself.
301
+ *
302
+ * When the host already opened this turn the confirmation is a no-op: re-seeding
303
+ * `/streaming` would clobber the blocks and text the turn has produced since.
304
+ */
305
+ export function shouldOpenClaudeTurnOnRunning(state) {
306
+ return !isOpenClaudeTurnState(state);
307
+ }
308
+ /**
309
+ * Move the session into a fresh open turn: 'thinking', with a turn id, before
310
+ * a single SDK message has arrived.
311
+ *
312
+ * The id is minted per OPEN, unconditionally. Keying the mint on "the turn
313
+ * state is not open" reads equivalent and is not: the state a finished turn
314
+ * actually sits in during the final-handoff window is 'streaming'
315
+ * (`markPendingFinalDelivery` puts it there and only the reset clears it), so a
316
+ * message arriving in that window — precisely the case this exists for — would
317
+ * inherit an id that already carries a `turn_complete` message, and the client
318
+ * drops a live turn whose id is already complete.
319
+ *
320
+ * Nothing needs the guard: a turn is only ever opened when none is running —
321
+ * at dequeue, or on the SDK's `running` echo for a turn the host did not open
322
+ * itself (`shouldOpenClaudeTurnOnRunning` has already proved the state is not
323
+ * an open one there).
324
+ */
325
+ export function openClaudeTurn(session, now = Date.now()) {
326
+ session.currentTurnId = randomUUID();
327
+ session.currentTurnOpenedAt = now;
328
+ session.currentTurnUpdatedAt = now;
329
+ session.turnState = 'thinking';
330
+ session.toolInProgress = false;
331
+ session.pendingFinalText = null;
332
+ if (session.turnActivity)
333
+ resetClaudeTurnActivityState(session.turnActivity);
334
+ }
335
+ /**
336
+ * Whether an SDK message describes the agent's OWN work rather than a
337
+ * subagent's. Nested messages carry the id of the Task call that spawned them;
338
+ * that Task's row already stands for the whole subagent, and publishing its
339
+ * internals would flood the trail — and the 20-block budget the persisted turn
340
+ * trail is bounded to.
341
+ */
342
+ export function isClaudeMainTurnMessage(parentToolUseId) {
343
+ return parentToolUseId == null;
344
+ }
345
+ export function createClaudeTurnActivityState() {
346
+ return {
347
+ blockIdByIndex: new Map(),
348
+ blockIdByToolUseId: new Map(),
349
+ planBlockId: null,
350
+ };
351
+ }
352
+ export function resetClaudeTurnActivityState(state) {
353
+ state.blockIdByIndex.clear();
354
+ state.blockIdByToolUseId.clear();
355
+ state.planBlockId = null;
356
+ }
357
+ export function claudeToolBlockId(toolUseId) {
358
+ return buildTrailBlockId('tool', toolUseId);
359
+ }
360
+ export function claudePlanBlockId(turnId) {
361
+ return buildTrailBlockId('plan', turnId ?? 'turn');
362
+ }
363
+ /**
364
+ * This turn's plan block, claimed once and then reused.
365
+ *
366
+ * Reading the claimed id back — rather than re-deriving it from the turn id on
367
+ * every TodoWrite — is what keeps a plan card whole: a turn id that changed
368
+ * mid-turn would otherwise split the plan into a second card instead of
369
+ * updating the first.
370
+ */
371
+ export function claimClaudePlanBlockId(state, turnId) {
372
+ if (!state.planBlockId)
373
+ state.planBlockId = claudePlanBlockId(turnId);
374
+ return state.planBlockId;
375
+ }
376
+ function readTrimmedString(value) {
377
+ return normalizeTrailKey(value) ?? null;
378
+ }
379
+ /**
380
+ * Claim the trail block for a tool call the model has started emitting, and
381
+ * remember how to find it again from either the `tool_use` id (tool_result) or
382
+ * the content-block index (content_block_stop).
383
+ */
384
+ export function registerClaudeToolCallBlock(state, input) {
385
+ const toolUseId = readTrimmedString(input.toolUseId);
386
+ const index = typeof input.index === 'number' && Number.isInteger(input.index)
387
+ ? input.index
388
+ : null;
389
+ // Without an id (older runtimes) the index still separates the parallel calls
390
+ // of one assistant message, which is where collisions actually happen.
391
+ const blockId = toolUseId
392
+ ? claudeToolBlockId(toolUseId)
393
+ : buildTrailBlockId('tool', input.turnId ?? 'turn', index ?? state.blockIdByIndex.size);
394
+ if (toolUseId) {
395
+ state.blockIdByToolUseId.set(toolUseId, blockId);
396
+ boundTrailBlockMap(state.blockIdByToolUseId);
397
+ }
398
+ if (index !== null) {
399
+ state.blockIdByIndex.set(index, blockId);
400
+ boundTrailBlockMap(state.blockIdByIndex);
401
+ }
402
+ return blockId;
403
+ }
404
+ /** The block a `tool_result` belongs to. Null when this turn never claimed one. */
405
+ export function resolveClaudeToolBlockId(state, toolUseId) {
406
+ const id = readTrimmedString(toolUseId);
407
+ if (!id)
408
+ return null;
409
+ return state.blockIdByToolUseId.get(id) ?? null;
410
+ }
411
+ /**
412
+ * The block a `content_block_stop` closes, consumed on read — indexes restart
413
+ * at 0 with every assistant message, so a stale entry would mis-resolve.
414
+ */
415
+ export function takeClaudeToolBlockIdByIndex(state, index) {
416
+ if (typeof index !== 'number' || !Number.isInteger(index))
417
+ return null;
418
+ const blockId = state.blockIdByIndex.get(index) ?? null;
419
+ if (blockId)
420
+ state.blockIdByIndex.delete(index);
421
+ return blockId;
422
+ }
423
+ // ── Tool call presentation ───────────────────────────────────────────
424
+ const CLAUDE_TOOL_TITLE_MAX_CHARS = 120;
425
+ const CLAUDE_TOOL_FAILURE_SUMMARY_MAX_CHARS = 120;
426
+ /** TodoWrite is the plan card, not a tool receipt. */
427
+ export const CLAUDE_PLAN_TOOL_NAME = 'TodoWrite';
428
+ /** The margin row's title for the plan card, which is the card's own heading. */
429
+ export const CLAUDE_PLAN_BLOCK_TITLE = PLAN_BLOCK_TITLE;
430
+ function truncateClaudeToolText(value, maxChars) {
431
+ const collapsed = value.replace(/\s+/g, ' ').trim();
432
+ if (collapsed.length <= maxChars)
433
+ return collapsed;
434
+ let end = maxChars - 1;
435
+ // Cut on a code-point boundary. `slice` counts UTF-16 units, so a boundary
436
+ // that lands inside a surrogate pair (any emoji, and plenty of CJK) would
437
+ // leave a lone surrogate as the last character — an unpaired half that is not
438
+ // well-formed UTF-8 and renders as a replacement character wherever this
439
+ // title goes, from the live margin row to the persisted turn trail.
440
+ const lastUnit = collapsed.charCodeAt(end - 1);
441
+ if (lastUnit >= 0xd800 && lastUnit <= 0xdbff)
442
+ end -= 1;
443
+ return `${collapsed.slice(0, end)}…`;
444
+ }
445
+ function readToolInput(input, key) {
446
+ if (!input || typeof input !== 'object' || Array.isArray(input))
447
+ return null;
448
+ return readTrimmedString(input[key]);
449
+ }
450
+ function claudeToolCallDetail(toolName, input) {
451
+ switch (toolName) {
452
+ case 'Bash': {
453
+ const command = readToolInput(input, 'command');
454
+ return command ? `Running: ${command}` : null;
455
+ }
456
+ case 'BashOutput': {
457
+ const id = readToolInput(input, 'bash_id');
458
+ return id ? `Reading output of ${id}` : null;
459
+ }
460
+ case 'Read':
461
+ case 'Write':
462
+ case 'Edit': {
463
+ const path = readToolInput(input, 'file_path');
464
+ return path ? `${toolName} ${path}` : null;
465
+ }
466
+ case 'NotebookEdit': {
467
+ const path = readToolInput(input, 'notebook_path');
468
+ return path ? `Edit ${path}` : null;
469
+ }
470
+ case 'Grep':
471
+ case 'Glob': {
472
+ const pattern = readToolInput(input, 'pattern');
473
+ return pattern ? `${toolName} ${pattern}` : null;
474
+ }
475
+ case 'Task': {
476
+ const description = readToolInput(input, 'description');
477
+ return description ? `Task: ${description}` : null;
478
+ }
479
+ case 'WebFetch': {
480
+ const url = readToolInput(input, 'url');
481
+ return url ? `Fetch ${url}` : null;
482
+ }
483
+ case 'WebSearch': {
484
+ const query = readToolInput(input, 'query');
485
+ return query ? `Search ${query}` : null;
486
+ }
487
+ default:
488
+ return null;
489
+ }
490
+ }
491
+ /**
492
+ * The margin row's title for a tool call: what the agent actually did, not just
493
+ * which tool it reached for. Falls back to the tool name whenever the typed
494
+ * input is missing or shaped differently than expected — an unknown tool is
495
+ * still worth a row.
496
+ */
497
+ export function summarizeClaudeToolCall(name, input) {
498
+ const toolName = readTrimmedString(name) ?? 'Tool';
499
+ return truncateClaudeToolText(claudeToolCallDetail(toolName, input) ?? toolName, CLAUDE_TOOL_TITLE_MAX_CHARS);
500
+ }
501
+ function readToolResultText(content) {
502
+ const direct = readTrimmedString(content);
503
+ if (direct)
504
+ return direct;
505
+ if (!Array.isArray(content))
506
+ return null;
507
+ for (const entry of content) {
508
+ if (!entry || typeof entry !== 'object')
509
+ continue;
510
+ const text = readTrimmedString(entry.text);
511
+ if (text)
512
+ return text;
513
+ }
514
+ return null;
515
+ }
516
+ /** One-line reason for a failed tool call, for the receipt's summary slot. */
517
+ export function summarizeClaudeToolFailure(content) {
518
+ const text = readToolResultText(content);
519
+ return text
520
+ ? truncateClaudeToolText(text, CLAUDE_TOOL_FAILURE_SUMMARY_MAX_CHARS)
521
+ : 'Tool call failed';
522
+ }
523
+ const CLAUDE_ELAPSED_SUMMARY_MIN_SECONDS = 5;
524
+ /**
525
+ * Progress text for a call that is taking a while. Short calls get nothing:
526
+ * every summary change is an RTDB write, and "Running for 1s" says nothing the
527
+ * running row does not already say.
528
+ */
529
+ export function formatClaudeToolElapsed(seconds) {
530
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds))
531
+ return null;
532
+ const whole = Math.floor(seconds);
533
+ if (whole < CLAUDE_ELAPSED_SUMMARY_MIN_SECONDS)
534
+ return null;
535
+ if (whole < 60)
536
+ return `Running for ${whole}s`;
537
+ const minutes = Math.floor(whole / 60);
538
+ const remainder = whole % 60;
539
+ return remainder
540
+ ? `Running for ${minutes}m ${remainder}s`
541
+ : `Running for ${minutes}m`;
542
+ }
543
+ // ── Plan card ────────────────────────────────────────────────────────
544
+ const CLAUDE_PLAN_MAX_ITEMS = 30;
545
+ const CLAUDE_PLAN_ITEM_MAX_CHARS = 120;
546
+ /**
547
+ * TodoWrite's typed input mapped onto the shared plan grammar (`renderPlanSteps`
548
+ * in @canonmsg/coding-agent-host), which both coding-agent hosts render through
549
+ * so a plan card reads the same whichever runtime produced it. What stays here
550
+ * is what is genuinely Claude's: the SDK's todo shape, its `activeForm` wording
551
+ * for the step being worked on ("Running the tests"), the per-step and
552
+ * per-plan bounds, and publishing NOTHING rather than a bare heading when a
553
+ * TodoWrite call carries no usable step.
554
+ *
555
+ * Unusable entries are dropped before rendering, so the numbering the reader
556
+ * sees stays contiguous.
557
+ */
558
+ export function renderTodosAsPlan(todos) {
559
+ if (!Array.isArray(todos))
560
+ return null;
561
+ const steps = [];
562
+ for (const entry of todos.slice(0, CLAUDE_PLAN_MAX_ITEMS)) {
563
+ if (!entry || typeof entry !== 'object')
564
+ continue;
565
+ const record = entry;
566
+ const content = readTrimmedString(record.content);
567
+ const activeForm = readTrimmedString(record.activeForm);
568
+ const status = normalizePlanStepStatus(record.status);
569
+ const label = status === 'in_progress'
570
+ ? activeForm ?? content
571
+ : content ?? activeForm;
572
+ if (!label)
573
+ continue;
574
+ steps.push({ text: truncateClaudeToolText(label, CLAUDE_PLAN_ITEM_MAX_CHARS), status });
575
+ }
576
+ if (steps.length === 0)
577
+ return null;
578
+ return renderPlanSteps(steps);
579
+ }
580
+ /**
581
+ * `content_block_start` for a `tool_use`: the call claims its row, titled with
582
+ * the tool's name until the typed input arrives on the assistant message.
583
+ */
584
+ export function planClaudeToolCallStart(state, input) {
585
+ // Registered even for TodoWrite: `content_block_stop` resolves the turn out of
586
+ // its 'tool' state by index, and an unclaimed index would leave it stuck.
587
+ const blockId = registerClaudeToolCallBlock(state, input);
588
+ const toolName = readTrimmedString(input.name);
589
+ // TodoWrite is the plan card, published once its todos arrive; no receipt.
590
+ if (toolName === CLAUDE_PLAN_TOOL_NAME)
591
+ return [];
592
+ return [{
593
+ op: 'add',
594
+ id: blockId,
595
+ kind: 'tool',
596
+ status: 'running',
597
+ title: toolName ?? 'Claude tool use',
598
+ }];
599
+ }
600
+ /**
601
+ * The assistant message is the first place a call's typed INPUT is visible —
602
+ * `content_block_start` carries only the name. It is what turns "Bash" into
603
+ * "Running: npm test" and TodoWrite into the plan card.
604
+ */
605
+ export function planClaudeAssistantTrail(state, content, turnId) {
606
+ if (!Array.isArray(content))
607
+ return [];
608
+ const commands = [];
609
+ for (const block of content) {
610
+ if (!block || typeof block !== 'object')
611
+ continue;
612
+ const candidate = block;
613
+ if (candidate.type !== 'tool_use')
614
+ continue;
615
+ if (candidate.name === CLAUDE_PLAN_TOOL_NAME) {
616
+ const text = renderTodosAsPlan(candidate.input?.todos);
617
+ if (!text)
618
+ continue;
619
+ // One stable id per turn, so the card updates in place as steps tick over
620
+ // instead of stacking a new card per TodoWrite call.
621
+ commands.push({
622
+ op: 'add',
623
+ id: claimClaudePlanBlockId(state, turnId),
624
+ kind: 'plan',
625
+ status: 'running',
626
+ title: CLAUDE_PLAN_BLOCK_TITLE,
627
+ text,
628
+ });
629
+ continue;
630
+ }
631
+ // Without an id there is no way to tell whether this call already has a
632
+ // row; claiming another one would duplicate it. The name-only row that
633
+ // content_block_start published stands.
634
+ if (!readTrimmedString(candidate.id))
635
+ continue;
636
+ // Registering here as well as on content_block_start keeps the receipt
637
+ // (and its later completion) working when partial messages are off.
638
+ const blockId = registerClaudeToolCallBlock(state, { toolUseId: candidate.id, turnId });
639
+ commands.push({
640
+ op: 'add',
641
+ id: blockId,
642
+ // No status: an existing row keeps whatever it has, so a result that
643
+ // somehow landed first is not reopened.
644
+ kind: 'tool',
645
+ title: summarizeClaudeToolCall(candidate.name, candidate.input),
646
+ });
647
+ }
648
+ return commands;
649
+ }
650
+ /**
651
+ * `tool_result` on a `user` message: where a call's OUTCOME finally arrives.
652
+ * Rows settle here and nowhere else — `content_block_stop` only means the model
653
+ * finished writing the call.
654
+ */
655
+ export function planClaudeToolResults(state, content) {
656
+ if (!Array.isArray(content))
657
+ return [];
658
+ const commands = [];
659
+ for (const block of content) {
660
+ if (!block || typeof block !== 'object')
661
+ continue;
662
+ const candidate = block;
663
+ if (candidate.type !== 'tool_result')
664
+ continue;
665
+ // Keyed by tool_use id, so results that come back out of order still
666
+ // resolve the row that asked for them.
667
+ const blockId = resolveClaudeToolBlockId(state, candidate.tool_use_id);
668
+ if (!blockId)
669
+ continue;
670
+ if (candidate.is_error === true) {
671
+ commands.push({ op: 'fail', id: blockId, summary: summarizeClaudeToolFailure(candidate.content) });
672
+ }
673
+ else {
674
+ // The empty summary drops any progress note the row picked up while it
675
+ // ran: the last reading is not the call's duration (progress stops
676
+ // arriving before the tool returns), and "Running for 12s" on a row that
677
+ // has settled reads as a call still in flight.
678
+ commands.push({ op: 'complete', id: blockId, summary: '' });
679
+ }
680
+ }
681
+ return commands;
682
+ }
683
+ /** `tool_progress`: a long call says how long it has been going. */
684
+ export function planClaudeToolProgress(state, input) {
685
+ const blockId = resolveClaudeToolBlockId(state, input.toolUseId);
686
+ const elapsed = formatClaudeToolElapsed(input.elapsedSeconds);
687
+ if (!blockId || !elapsed)
688
+ return [];
689
+ return [{ op: 'update', id: blockId, summary: elapsed }];
63
690
  }
64
691
  // Provenance for a Canon message entering the SDK. From 0.3.220 an absent
65
692
  // `origin` is "unattributed" and fails closed at strict isHuman() gates, so a
@@ -87,17 +714,15 @@ export function buildClaudeFinalTurnMetadata(input) {
87
714
  ...(input.turnTrail && input.turnTrail.length > 0 ? { turnTrail: input.turnTrail } : {}),
88
715
  };
89
716
  }
90
- const CLAUDE_FAILURE_DETAIL_MAX_CHARS = 280;
91
717
  function claudeFailureDetail(errors) {
92
718
  if (!Array.isArray(errors))
93
719
  return null;
94
720
  const first = errors.find((entry) => typeof entry === 'string' && entry.trim());
95
721
  if (typeof first !== 'string')
96
722
  return null;
97
- const trimmed = first.trim();
98
- return trimmed.length > CLAUDE_FAILURE_DETAIL_MAX_CHARS
99
- ? `${trimmed.slice(0, CLAUDE_FAILURE_DETAIL_MAX_CHARS - 1)}…`
100
- : trimmed;
723
+ // The shared bound on error text quoted back into a chat message, so no
724
+ // notice can let a multi-KB error body push itself over Canon's size limit.
725
+ return truncateFailureDetail(first);
101
726
  }
102
727
  function claudeUsageLimitDetail(errors) {
103
728
  if (!Array.isArray(errors))
@@ -144,6 +769,253 @@ export function composeClaudeFinalText(input) {
144
769
  return primary;
145
770
  return primary ? `${primary}\n\n${input.failureNotice}` : input.failureNotice;
146
771
  }
772
+ /**
773
+ * How the host should react to a failed final-reply send.
774
+ *
775
+ * Core's `isRetryableCanonDeliveryError` is already right about what a retry
776
+ * can fix (429, 5xx, transport errors); what the host lacked was the other
777
+ * half — everything else is PERMANENT and must be surfaced instead of retried.
778
+ * A 409 from the server's idempotency guard means a message already exists
779
+ * under this id, so the reply is durable: that is a success, not a failure.
780
+ *
781
+ * A CHUNKED final is the exception (#616's rule, kept). A chunked send is N
782
+ * sequential messages that abort on the first failure, so a 409 says only that
783
+ * ONE part id is taken, with nothing to say which — and since the part ids are
784
+ * a pure hash of agent + conversation + turnKey, an id can be taken by an
785
+ * ENTIRELY DIFFERENT answer to the same inbound message, written by a previous
786
+ * process. Reading that as "the final is delivered" would finalize the turn
787
+ * with the tail never sent, so it stays 'permanent': a stops-short notice beats
788
+ * silently dropping the end of an answer.
789
+ *
790
+ * Resume support does not soften this. Core absorbs the one conflict that IS
791
+ * provably ours — the first part of a resumed attempt, whose write may have
792
+ * landed before its response was lost — and reports every other one, so a
793
+ * conflict reaching this function is precisely the case that must not be read
794
+ * as success.
795
+ */
796
+ export function classifyFinalDeliveryFailure(error, options = {}) {
797
+ if (isCanonMessageIdConflict(error) && !options.chunked) {
798
+ return 'already-delivered';
799
+ }
800
+ return isRetryableCanonDeliveryError(error) ? 'retry' : 'permanent';
801
+ }
802
+ /**
803
+ * The pending-delivery record for a final that has to be retried, carrying the
804
+ * progress the next attempt needs.
805
+ *
806
+ * Progress is monotonic: a later attempt can only ever add parts, so the longer
807
+ * of the two lists wins and a retry can never rewind the cursor. The metadata
808
+ * and the self-context are pinned to the FIRST attempt's values, because those
809
+ * are what the durable parts were fingerprinted against.
810
+ */
811
+ export function buildClaudePendingFinalDelivery(input) {
812
+ const previous = input.previous?.turnKey === input.turnKey ? input.previous : null;
813
+ const previousDelivered = previous?.deliveredMessageIds ?? [];
814
+ const nextDelivered = input.deliveredMessageIds ?? [];
815
+ const delivered = nextDelivered.length >= previousDelivered.length
816
+ ? [...nextDelivered]
817
+ : [...previousDelivered];
818
+ const metadata = previous?.metadata ?? input.metadata;
819
+ // `previous` may pin a null self-context (a conversation with none), which
820
+ // must still win over a later inbound message's value — hence the key check
821
+ // rather than `??`.
822
+ const selfContextId = previous && 'selfContextId' in previous
823
+ ? previous.selfContextId
824
+ : input.selfContextId;
825
+ const lastErrorMessage = input.lastErrorMessage ?? previous?.lastErrorMessage;
826
+ return {
827
+ turnKey: input.turnKey,
828
+ text: input.text,
829
+ messageId: input.messageId,
830
+ retryCount: previous?.retryCount ?? 0,
831
+ ...(input.suppressAutoReply ? { suppressAutoReply: true } : {}),
832
+ ...(delivered.length > 0 ? { deliveredMessageIds: delivered } : {}),
833
+ ...(metadata ? { metadata } : {}),
834
+ ...(selfContextId !== undefined ? { selfContextId } : {}),
835
+ ...(lastErrorMessage ? { lastErrorMessage: truncateFailureDetail(lastErrorMessage) } : {}),
836
+ };
837
+ }
838
+ /**
839
+ * What a retry of this turn's final should reuse: the parts already durable,
840
+ * and the metadata and self-context they were written with. Empty for a first
841
+ * attempt, or when the pending record belongs to a different turn.
842
+ */
843
+ export function readClaudeFinalDeliveryResume(pending, turnKey) {
844
+ if (!pending || pending.turnKey !== turnKey)
845
+ return { deliveredMessageIds: [] };
846
+ return {
847
+ deliveredMessageIds: [...(pending.deliveredMessageIds ?? [])],
848
+ ...(pending.metadata ? { metadata: pending.metadata } : {}),
849
+ ...('selfContextId' in pending ? { selfContextId: pending.selfContextId } : {}),
850
+ };
851
+ }
852
+ /**
853
+ * The chunking options a final send opts into, built from the resume cursor.
854
+ *
855
+ * A helper rather than an inline literal so the opt-in is one testable seam:
856
+ * dropping `resumable` restores restart-from-part-1, which no assertion on a
857
+ * hand-built call could catch.
858
+ */
859
+ export function buildClaudeFinalChunkingOptions(resume) {
860
+ return { resumable: true, deliveredMessageIds: resume.deliveredMessageIds };
861
+ }
862
+ /** True when a final of this size will be split across several messages. */
863
+ export function claudeFinalWillChunk(finalText, maxTextBytes = DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES) {
864
+ // Mirrors core's splitTextByUtf8Bytes threshold so the host's pre-flight and
865
+ // the actual send always agree on whether chunking happens.
866
+ return utf8ByteLength(finalText) > maxTextBytes;
867
+ }
868
+ /**
869
+ * The turn trail to attach to a final that is about to be sent.
870
+ *
871
+ * A chunked final's LAST message carries the answer's tail, while the trail's
872
+ * text blocks carry the compacted head. chat-domain's `buildTurnRenderParts`
873
+ * appends `message.text` after structured speech that is not a prefix of it, so
874
+ * an unfiltered trail would render the head again above the tail. Text blocks
875
+ * are therefore dropped from a chunked final; tool/plan/approval/status blocks
876
+ * are the margin's activity record and stay. Unchunked finals are untouched,
877
+ * which keeps the common case byte-identical to before.
878
+ */
879
+ export function prepareTurnTrailForDelivery(trail, willChunk) {
880
+ if (!willChunk)
881
+ return [...trail];
882
+ return trail.filter((block) => block.kind !== 'text');
883
+ }
884
+ export const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated — the full answer exceeded Canon's message size limit]_";
885
+ /** How far back from the cut a paragraph break is still worth cutting at. */
886
+ const CLAUDE_TRUNCATION_BOUNDARY_WINDOW_CHARS = 400;
887
+ function sliceByUtf8Bytes(text, maxBytes) {
888
+ let out = '';
889
+ let bytes = 0;
890
+ // Iterating code points (not UTF-16 units) keeps surrogate pairs intact,
891
+ // matching how core splits chunks.
892
+ for (const char of text) {
893
+ const charBytes = utf8ByteLength(char);
894
+ if (bytes + charBytes > maxBytes)
895
+ break;
896
+ out += char;
897
+ bytes += charBytes;
898
+ }
899
+ return out;
900
+ }
901
+ /**
902
+ * Last-resort degradation for a final Canon refuses to accept: keep as much of
903
+ * the answer as fits in ONE message and say that the rest was dropped. Prefers
904
+ * to cut at a paragraph break near the end so the survivor reads as prose. The
905
+ * result — marker included — always fits the byte budget.
906
+ *
907
+ * Deliberate trade-off: this keeps the HEAD, so a trailing paragraph is lost —
908
+ * including the turn-failure notice `composeClaudeFinalText` appends last. That
909
+ * is acceptable only because the host calls this on a final that already fits
910
+ * (an oversized final is chunked, never truncated), so nothing is actually cut;
911
+ * it is the bound that keeps the degraded send legal, not a text editor. A
912
+ * caller that truncates for real must re-attach its own trailing notice.
913
+ */
914
+ export function buildTruncatedFinalText(finalText, maxTextBytes = DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES) {
915
+ if (utf8ByteLength(finalText) <= maxTextBytes)
916
+ return finalText;
917
+ const markerBytes = utf8ByteLength(CLAUDE_FINAL_TRUNCATION_MARKER);
918
+ const budget = maxTextBytes - markerBytes;
919
+ if (budget <= 0)
920
+ return CLAUDE_FINAL_TRUNCATION_MARKER.trim();
921
+ const head = sliceByUtf8Bytes(finalText, budget);
922
+ const windowStart = Math.max(0, head.length - CLAUDE_TRUNCATION_BOUNDARY_WINDOW_CHARS);
923
+ const boundary = head.lastIndexOf('\n\n');
924
+ const kept = boundary > 0 && boundary >= windowStart ? head.slice(0, boundary) : head;
925
+ return `${kept.trimEnd()}${CLAUDE_FINAL_TRUNCATION_MARKER}`;
926
+ }
927
+ /**
928
+ * Server-side `metadata` cap, taken from the shared contract rather than
929
+ * re-typed here. Note the UNIT: unlike message text, which the server measures
930
+ * in UTF-8 bytes, metadata is capped by `JSON.stringify(metadata).length` —
931
+ * UTF-16 code units — in both places it is checked (the request schema's
932
+ * misleadingly named `maxJsonBytes` and the handler's inline check). Mirroring
933
+ * it in bytes would be a different, stricter rule that drops the margin's
934
+ * activity record on payloads Canon would have accepted.
935
+ */
936
+ export const CLAUDE_MESSAGE_METADATA_MAX_CHARS = VERB_LIMITS.messageMetadataJsonChars;
937
+ /**
938
+ * Defensive pre-flight for message metadata. Hosts already bound the trail via
939
+ * core's `buildBoundedTurnTrail`, so this should never fire; if it ever does,
940
+ * losing the margin's activity record beats losing the answer.
941
+ */
942
+ export function boundClaudeFinalMetadata(metadata, maxChars = CLAUDE_MESSAGE_METADATA_MAX_CHARS) {
943
+ if (JSON.stringify(metadata).length <= maxChars)
944
+ return metadata;
945
+ const { turnTrail: _turnTrail, ...rest } = metadata;
946
+ return rest;
947
+ }
948
+ /**
949
+ * How Claude says a final could not be delivered. The sentence's shape and the
950
+ * bound on the quoted error are shared with the other coding-agent hosts; only
951
+ * the voice is Claude's — it speaks as itself, in the first person, because
952
+ * that is who the reader has been talking to all turn.
953
+ */
954
+ const CLAUDE_UNDELIVERABLE_FINAL_WORDING = {
955
+ lead: 'I finished this turn, but Canon rejected the reply',
956
+ partialLead: 'I finished this turn, but Canon rejected the rest of the reply, so it stops short',
957
+ };
958
+ /**
959
+ * The standalone notice for a final Canon will not accept — the last thing the
960
+ * host can say when the answer itself cannot be delivered.
961
+ *
962
+ * `partiallyDelivered` marks a chunked final whose earlier parts are already in
963
+ * the conversation: the reader can see text, and needs to be told it stops
964
+ * short rather than being left to assume the answer ended there.
965
+ */
966
+ export function buildUndeliverableFinalNotice(input) {
967
+ return buildHostUndeliverableFinalNotice({
968
+ ...input,
969
+ wording: CLAUDE_UNDELIVERABLE_FINAL_WORDING,
970
+ });
971
+ }
972
+ /**
973
+ * What is left to say when the bounded retries for a final run out.
974
+ *
975
+ * With parts of a chunked answer already in the conversation, silence is the
976
+ * wrong ending: the reader takes the last delivered part for the end of the
977
+ * answer. That case gets the stops-short notice, which completes the turn.
978
+ *
979
+ * With NOTHING delivered there is deliberately no notice, so the host can clear
980
+ * `/streaming` and let functions' `onStreamingCleared` preserve the streamed
981
+ * text as a durable progress message — a notice would complete the turn and
982
+ * suppress exactly that fallback, leaving the reader with an apology and no
983
+ * answer at all.
984
+ */
985
+ export function planExhaustedFinalDelivery(pending) {
986
+ const partiallyDelivered = (pending.deliveredMessageIds?.length ?? 0) > 0;
987
+ return {
988
+ partiallyDelivered,
989
+ notice: partiallyDelivered
990
+ ? buildUndeliverableFinalNotice({
991
+ error: pending.lastErrorMessage ?? 'delivery retries were exhausted',
992
+ partiallyDelivered: true,
993
+ })
994
+ : null,
995
+ };
996
+ }
997
+ /**
998
+ * End a final whose retries ran out: say it stops short, THEN retire the turn.
999
+ *
1000
+ * The order is the point, which is why the sequence lives here. The pending
1001
+ * record is what makes the session read busy to inbound messages, and the
1002
+ * notice is a network send with its own retries — clearing the record first
1003
+ * left the session 'running' with no timer and no pending delivery, so an
1004
+ * interrupt arriving in that window fired `query.interrupt()` at an SDK whose
1005
+ * turn had already produced its result, flipped the turn row to 'interrupted'
1006
+ * and cleared `/streaming` out from under the retirement.
1007
+ */
1008
+ export async function runClaudeExhaustedFinalDelivery(session, pending, deps) {
1009
+ const { notice } = planExhaustedFinalDelivery(pending);
1010
+ if (notice) {
1011
+ // Best effort: the notice failing is no reason to leave the turn open.
1012
+ await deps.sendNotice(notice).catch(() => { });
1013
+ }
1014
+ if (session.pendingFinalDelivery?.turnKey === pending.turnKey) {
1015
+ session.pendingFinalDelivery = null;
1016
+ }
1017
+ deps.retireTurn();
1018
+ }
147
1019
  // Model options are discovery-driven: the host publishes whatever the Claude
148
1020
  // Code CLI it spawns reports. A CLI older than this predates whole model
149
1021
  // families (2.1.220 is the first to know Opus 5), so an old binary silently