@canonmsg/claude-code-plugin 0.30.0 → 0.31.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,7 +1,7 @@
1
1
  {
2
2
  "name": "Canon",
3
3
  "description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
4
- "version": "0.30.0",
4
+ "version": "0.31.0",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/dist/host.js CHANGED
@@ -27,13 +27,13 @@ import { existsSync } from 'node:fs';
27
27
  import { readFile } from 'node:fs/promises';
28
28
  import { query, } from '@anthropic-ai/claude-agent-sdk';
29
29
  import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
30
- import { captureTurnArtifactSnapshot, collectTurnArtifacts, IDLE_TIMEOUT_MS, collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
31
- import { EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
30
+ import { captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
31
+ import { EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
32
32
  import { runCli } from '@canonmsg/core';
33
33
  import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
34
34
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
35
35
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
36
- import { applyClaudeSessionControl, boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeFinalText, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
36
+ import { applyClaudeSessionControl, boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeTurnFinal, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, shouldRouteClaudeTurnArtifacts, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
37
37
  import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
38
38
  function parseRuntimeVisibilityPreset(value) {
39
39
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
@@ -1600,37 +1600,32 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1600
1600
  metadata: buildArtifactMediaMetadata(),
1601
1601
  });
1602
1602
  }
1603
- async function routeWorkspaceGeneratedArtifacts(input) {
1604
- if (!input.artifactBaseline)
1605
- return;
1606
- try {
1607
- const result = await collectTurnArtifacts({
1608
- cwd: session.cwd,
1609
- baseline: input.artifactBaseline,
1610
- });
1611
- for (const file of result.files) {
1612
- try {
1613
- const { messageId } = await sendTurnArtifactFile(file);
1614
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Routed generated artifact ${file.relativePath ?? file.fileName} (${messageId})`);
1615
- }
1616
- catch (error) {
1617
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact upload failed for ${file.relativePath ?? file.fileName}: ${error instanceof Error ? error.message : String(error)}`);
1618
- }
1619
- }
1620
- for (const skipped of result.skipped) {
1621
- if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'scan-limit') {
1622
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact skipped ${skipped.fileName} (${skipped.reason})`);
1623
- }
1624
- }
1625
- }
1626
- catch (error) {
1627
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact routing failed:`, error instanceof Error ? error.message : error);
1628
- }
1629
- }
1630
- async function routeArtifactsForInput(input) {
1631
- if (input.artifactRoutingMode === 'workspace-generated') {
1632
- await routeWorkspaceGeneratedArtifacts(input);
1633
- }
1603
+ /**
1604
+ * The one path from a completed turn to posting its workspace artifacts.
1605
+ *
1606
+ * The router owns the collect-and-post loop, so no completion branch can
1607
+ * reach the workspace around the gate. It is built per turn rather than per
1608
+ * session because its once-only latch is a TURN's latch — Codex reaches
1609
+ * completion through five branches and needs it; this host calls it once,
1610
+ * and gets the same guarantee for free.
1611
+ *
1612
+ * `finalText` is the model's own reply, which decides whether the turn's
1613
+ * silence actually suppresses anything — the same reading
1614
+ * `composeClaudeTurnFinal` makes for the text.
1615
+ */
1616
+ function routeArtifactsForInput(input, finalText) {
1617
+ return createTurnArtifactRouter({
1618
+ decide: () => shouldRouteClaudeTurnArtifacts({
1619
+ turn: input,
1620
+ interruptedTurnKeys: session.interruptedTurnKeys,
1621
+ silencedTurnKeys: session.silencedTurnKeys,
1622
+ finalText,
1623
+ }),
1624
+ baseline: () => input.artifactBaseline,
1625
+ cwd: () => session.cwd,
1626
+ send: (file) => sendTurnArtifactFile(file),
1627
+ log: (line) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] ${line}`),
1628
+ }).route();
1634
1629
  }
1635
1630
  function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
1636
1631
  if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
@@ -2218,11 +2213,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2218
2213
  // working, at their existing accuracy.
2219
2214
  const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid) ?? session.activeInput;
2220
2215
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
2221
- // Turn artifacts land before the final text reply.
2222
- if (completedInput?.kind === 'canon'
2223
- && !session.interruptedTurnKeys.has(completedInput.turnKey)) {
2224
- await routeArtifactsForInput(completedInput);
2225
- }
2226
2216
  const resultText = typeof msg.result === 'string'
2227
2217
  ? msg.result.trim()
2228
2218
  : '';
@@ -2237,25 +2227,42 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2237
2227
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn failed (${msg.subtype}): `
2238
2228
  + (errorList.join(' | ') || 'no error detail'));
2239
2229
  }
2240
- const finalText = composeClaudeFinalText({
2230
+ const silenced = Boolean(completedInput?.turnKey && session.silencedTurnKeys.has(completedInput.turnKey));
2231
+ // Composed before the artifacts are routed, because the artifact
2232
+ // gate weighs silence against this same model text — a turn cannot
2233
+ // speak and withhold its files, or the reverse. Composing is pure;
2234
+ // the artifacts still POST first, ahead of the reply they belong to.
2235
+ const turnFinal = composeClaudeTurnFinal({
2241
2236
  resultText,
2242
2237
  streamedText: session.pendingFinalText,
2243
2238
  failureNotice,
2239
+ silenced,
2244
2240
  });
2245
- const silenced = Boolean(completedInput?.turnKey && session.silencedTurnKeys.has(completedInput.turnKey));
2246
- // `no_reply` suppresses the MODEL's reply, never Canon's own "this
2247
- // turn broke" diagnostic: a turn that goes silent and then fails
2248
- // would otherwise leave the owner with nothing at all, which is the
2249
- // exact outcome the notice exists to prevent.
2250
- const suppressFinal = silenced && !failureNotice;
2251
- const delivery = resolveSilentTurnDelivery({ silenced: suppressFinal, finalText });
2252
- const shouldDeliverFinal = delivery === 'deliver'
2241
+ const finalText = turnFinal.finalText;
2242
+ if (completedInput?.kind === 'canon') {
2243
+ await routeArtifactsForInput(completedInput, turnFinal.modelText);
2244
+ }
2245
+ const shouldDeliverFinal = finalText
2253
2246
  ? shouldDeliverClaudeFinal({
2254
2247
  turn: completedInput,
2255
2248
  finalizedTurnKeys: session.finalizedTurnKeys,
2256
2249
  interruptedTurnKeys: session.interruptedTurnKeys,
2257
2250
  })
2258
2251
  : false;
2252
+ // Silence drops the model's own words without sending them. Say so:
2253
+ // a suppressed final is invisible in the conversation, and the
2254
+ // failure notice that may follow it is Canon's line, not the
2255
+ // model's, so nothing else would record what was swallowed.
2256
+ if (turnFinal.speechSuppressed && turnFinal.modelText) {
2257
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
2258
+ + `Final not delivered (${turnFinal.modelText.length} chars): `
2259
+ + describeUndeliveredClaudeFinal({
2260
+ turn: completedInput,
2261
+ finalizedTurnKeys: session.finalizedTurnKeys,
2262
+ interruptedTurnKeys: session.interruptedTurnKeys,
2263
+ silencedTurnKeys: session.silencedTurnKeys,
2264
+ }));
2265
+ }
2259
2266
  // Gated finals are dropped without sending. That is correct for the
2260
2267
  // seed's own turn, but it is also how a misattributed Canon reply
2261
2268
  // disappears — silently, which is why this class of bug went unseen.
@@ -2267,7 +2274,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2267
2274
  turn: completedInput,
2268
2275
  finalizedTurnKeys: session.finalizedTurnKeys,
2269
2276
  interruptedTurnKeys: session.interruptedTurnKeys,
2270
- silencedTurnKeys: suppressFinal ? session.silencedTurnKeys : undefined,
2271
2277
  }));
2272
2278
  }
2273
2279
  const finalDelivered = finalText && shouldDeliverFinal
@@ -2298,7 +2304,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2298
2304
  if (shouldDeliverFinal && finalText) {
2299
2305
  scheduleFinalHandoffReset();
2300
2306
  }
2301
- else if (suppressFinal) {
2307
+ else if (turnFinal.speechSuppressed) {
2302
2308
  // Deliberate silence: blank the node (text '' AND an explicit
2303
2309
  // empty blocks array) before deleting it, or onStreamingCleared
2304
2310
  // salvages this turn's narration into a durable bubble. Teardown
@@ -1,6 +1,6 @@
1
1
  import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
3
- import type { TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
3
+ import type { TurnArtifactRoutingDecision, TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
4
4
  export type ClaudeInputKind = 'seed' | 'canon';
5
5
  export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
6
6
  export interface ClaudeInputEnvelope {
@@ -118,6 +118,23 @@ export declare function shouldDeliverClaudeFinal(input: {
118
118
  finalizedTurnKeys: ReadonlySet<string>;
119
119
  interruptedTurnKeys: ReadonlySet<string>;
120
120
  }): boolean;
121
+ /**
122
+ * The artifact half of `shouldDeliverClaudeFinal`: workspace media is posted as
123
+ * a message, so the same turn-level gates apply to it. Silence is the one this
124
+ * host was missing — see `resolveTurnArtifactRouting` for the ruling.
125
+ *
126
+ * The only place silence is resolved for Claude artifacts. `finalText` is the
127
+ * model's own reply for the turn (never Canon's failure notice, which is a host
128
+ * diagnostic and goes out either way), weighed by `isSilentTurnSuppressed` —
129
+ * the same switch `composeClaudeTurnFinal` reads. One switch, so flipping
130
+ * `DEFAULT_SILENT_TURN_PRECEDENCE` moves a turn's text and its files together.
131
+ */
132
+ export declare function shouldRouteClaudeTurnArtifacts(input: {
133
+ turn: Pick<ClaudeInputEnvelope, 'artifactRoutingMode' | 'turnKey'>;
134
+ interruptedTurnKeys: ReadonlySet<string>;
135
+ silencedTurnKeys: ReadonlySet<string>;
136
+ finalText: string | null | undefined;
137
+ }): TurnArtifactRoutingDecision;
121
138
  export declare function rememberDispatchedClaudeInput(dispatched: Map<string, ClaudeInputEnvelope>, input: ClaudeInputEnvelope): void;
122
139
  /**
123
140
  * The envelope a turn result belongs to, matched by `user_message_uuid`.
@@ -497,6 +514,41 @@ export declare function composeClaudeFinalText(input: {
497
514
  streamedText?: string | null;
498
515
  failureNotice?: string | null;
499
516
  }): string | null;
517
+ export interface ClaudeTurnFinal {
518
+ /** What actually goes to Canon, or null when the turn says nothing. */
519
+ finalText: string | null;
520
+ /** The model's own text for the turn, whether or not it is delivered. */
521
+ modelText: string | null;
522
+ /**
523
+ * Silence — not "no text" — is why the model's own words are being dropped.
524
+ * The artifact gate reads the same verdict, so a silenced turn withholds its
525
+ * files and its speech together.
526
+ */
527
+ speechSuppressed: boolean;
528
+ }
529
+ /**
530
+ * What a completed turn actually says, once `no_reply` is taken into account.
531
+ *
532
+ * Silence suppresses the MODEL's output and nothing else — its result text or,
533
+ * failing that, the text it streamed before going quiet. Canon's own "this turn
534
+ * broke" notice still goes out: suppressing it would leave the owner with a
535
+ * turn that failed and said nothing, which is exactly what the notice exists to
536
+ * prevent.
537
+ *
538
+ * The two used to be merged. `composeClaudeFinalText` appends the notice to
539
+ * whatever partial text exists, and the host then turned silence OFF entirely
540
+ * whenever a notice was present — so a turn that streamed "Here's the chart:",
541
+ * called `no_reply`, and then hit `error_max_turns` posted the model's line
542
+ * next to the notice while withholding the chart itself. Codex has never done
543
+ * that: its failure branches send `formatCodexTurnFailure(...)` alone and never
544
+ * `result.finalMessage`. This is the same contract on both hosts.
545
+ */
546
+ export declare function composeClaudeTurnFinal(input: {
547
+ resultText?: string | null;
548
+ streamedText?: string | null;
549
+ failureNotice?: string | null;
550
+ silenced: boolean;
551
+ }): ClaudeTurnFinal;
500
552
  export type ClaudeFinalDeliveryFailure = 'retry' | 'permanent';
501
553
  /**
502
554
  * How the host should react to a failed final-reply send.
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk';
3
- import { buildBoundedTurnTrail, DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isRetryableCanonDeliveryError, shouldPublishTurnTrail, 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
+ import { buildBoundedTurnTrail, DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isRetryableCanonDeliveryError, isSilentTurnSuppressed, shouldPublishTurnTrail, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
4
+ import { boundTrailBlockMap, buildTrailBlockId, buildUndeliverableFinalNotice as buildHostUndeliverableFinalNotice, normalizePlanStepStatus, normalizeTrailKey, PLAN_BLOCK_TITLE, renderPlanSteps, resolveTurnArtifactRouting, truncateFailureDetail, } from '@canonmsg/coding-agent-host';
5
5
  export function createClaudeInputEnvelope(input) {
6
6
  const sourceMessageId = input.sourceMessageId ?? null;
7
7
  const fallbackId = randomUUID();
@@ -60,6 +60,27 @@ export function shouldDeliverClaudeFinal(input) {
60
60
  return false;
61
61
  return true;
62
62
  }
63
+ /**
64
+ * The artifact half of `shouldDeliverClaudeFinal`: workspace media is posted as
65
+ * a message, so the same turn-level gates apply to it. Silence is the one this
66
+ * host was missing — see `resolveTurnArtifactRouting` for the ruling.
67
+ *
68
+ * The only place silence is resolved for Claude artifacts. `finalText` is the
69
+ * model's own reply for the turn (never Canon's failure notice, which is a host
70
+ * diagnostic and goes out either way), weighed by `isSilentTurnSuppressed` —
71
+ * the same switch `composeClaudeTurnFinal` reads. One switch, so flipping
72
+ * `DEFAULT_SILENT_TURN_PRECEDENCE` moves a turn's text and its files together.
73
+ */
74
+ export function shouldRouteClaudeTurnArtifacts(input) {
75
+ return resolveTurnArtifactRouting({
76
+ artifactRoutingMode: input.turn.artifactRoutingMode,
77
+ interrupted: input.interruptedTurnKeys.has(input.turn.turnKey),
78
+ silenced: isSilentTurnSuppressed({
79
+ silenced: input.silencedTurnKeys.has(input.turn.turnKey),
80
+ finalText: input.finalText,
81
+ }),
82
+ });
83
+ }
63
84
  /** Bounds the correlation map if the SDK ever stops reporting a uuid we sent. */
64
85
  const MAX_TRACKED_DISPATCHED_INPUTS = 64;
65
86
  export function rememberDispatchedClaudeInput(dispatched, input) {
@@ -777,6 +798,45 @@ export function composeClaudeFinalText(input) {
777
798
  return primary;
778
799
  return primary ? `${primary}\n\n${input.failureNotice}` : input.failureNotice;
779
800
  }
801
+ /**
802
+ * What a completed turn actually says, once `no_reply` is taken into account.
803
+ *
804
+ * Silence suppresses the MODEL's output and nothing else — its result text or,
805
+ * failing that, the text it streamed before going quiet. Canon's own "this turn
806
+ * broke" notice still goes out: suppressing it would leave the owner with a
807
+ * turn that failed and said nothing, which is exactly what the notice exists to
808
+ * prevent.
809
+ *
810
+ * The two used to be merged. `composeClaudeFinalText` appends the notice to
811
+ * whatever partial text exists, and the host then turned silence OFF entirely
812
+ * whenever a notice was present — so a turn that streamed "Here's the chart:",
813
+ * called `no_reply`, and then hit `error_max_turns` posted the model's line
814
+ * next to the notice while withholding the chart itself. Codex has never done
815
+ * that: its failure branches send `formatCodexTurnFailure(...)` alone and never
816
+ * `result.finalMessage`. This is the same contract on both hosts.
817
+ */
818
+ export function composeClaudeTurnFinal(input) {
819
+ const modelText = composeClaudeFinalText({
820
+ resultText: input.resultText,
821
+ streamedText: input.streamedText,
822
+ });
823
+ const speechSuppressed = isSilentTurnSuppressed({
824
+ silenced: input.silenced,
825
+ finalText: modelText,
826
+ });
827
+ if (speechSuppressed) {
828
+ return {
829
+ finalText: input.failureNotice?.trim() || null,
830
+ modelText,
831
+ speechSuppressed: true,
832
+ };
833
+ }
834
+ return {
835
+ finalText: composeClaudeFinalText(input),
836
+ modelText,
837
+ speechSuppressed: false,
838
+ };
839
+ }
780
840
  /**
781
841
  * How the host should react to a failed final-reply send.
782
842
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -33,8 +33,8 @@
33
33
  "@anthropic-ai/claude-agent-sdk": "0.3.220",
34
34
  "@canonmsg/agent-sdk": "^8.2.0",
35
35
  "@canonmsg/agent-tools": "^0.4.0",
36
- "@canonmsg/coding-agent-host": "^0.4.0",
37
- "@canonmsg/core": "^10.0.0",
36
+ "@canonmsg/coding-agent-host": "^0.5.0",
37
+ "@canonmsg/core": "^10.1.0",
38
38
  "@canonmsg/rich-cards": "^0.9.1",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0"
40
40
  },