@canonmsg/claude-code-plugin 0.30.0 → 0.31.1

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.1",
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;
@@ -981,6 +981,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
981
981
  conversationId,
982
982
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
983
983
  ...(routing.responseUserId ? { responseUserId: routing.responseUserId } : {}),
984
+ ...(session.activeInput?.sourceMessageId
985
+ ? { sourceMessageId: session.activeInput.sourceMessageId }
986
+ : {}),
984
987
  };
985
988
  }, (verb) => {
986
989
  if (verb !== 'no_reply')
@@ -1600,37 +1603,32 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1600
1603
  metadata: buildArtifactMediaMetadata(),
1601
1604
  });
1602
1605
  }
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
- }
1606
+ /**
1607
+ * The one path from a completed turn to posting its workspace artifacts.
1608
+ *
1609
+ * The router owns the collect-and-post loop, so no completion branch can
1610
+ * reach the workspace around the gate. It is built per turn rather than per
1611
+ * session because its once-only latch is a TURN's latch — Codex reaches
1612
+ * completion through five branches and needs it; this host calls it once,
1613
+ * and gets the same guarantee for free.
1614
+ *
1615
+ * `finalText` is the model's own reply, which decides whether the turn's
1616
+ * silence actually suppresses anything — the same reading
1617
+ * `composeClaudeTurnFinal` makes for the text.
1618
+ */
1619
+ function routeArtifactsForInput(input, finalText) {
1620
+ return createTurnArtifactRouter({
1621
+ decide: () => shouldRouteClaudeTurnArtifacts({
1622
+ turn: input,
1623
+ interruptedTurnKeys: session.interruptedTurnKeys,
1624
+ silencedTurnKeys: session.silencedTurnKeys,
1625
+ finalText,
1626
+ }),
1627
+ baseline: () => input.artifactBaseline,
1628
+ cwd: () => session.cwd,
1629
+ send: (file) => sendTurnArtifactFile(file),
1630
+ log: (line) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] ${line}`),
1631
+ }).route();
1634
1632
  }
1635
1633
  function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
1636
1634
  if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
@@ -2218,11 +2216,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2218
2216
  // working, at their existing accuracy.
2219
2217
  const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid) ?? session.activeInput;
2220
2218
  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
2219
  const resultText = typeof msg.result === 'string'
2227
2220
  ? msg.result.trim()
2228
2221
  : '';
@@ -2237,25 +2230,42 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2237
2230
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn failed (${msg.subtype}): `
2238
2231
  + (errorList.join(' | ') || 'no error detail'));
2239
2232
  }
2240
- const finalText = composeClaudeFinalText({
2233
+ const silenced = Boolean(completedInput?.turnKey && session.silencedTurnKeys.has(completedInput.turnKey));
2234
+ // Composed before the artifacts are routed, because the artifact
2235
+ // gate weighs silence against this same model text — a turn cannot
2236
+ // speak and withhold its files, or the reverse. Composing is pure;
2237
+ // the artifacts still POST first, ahead of the reply they belong to.
2238
+ const turnFinal = composeClaudeTurnFinal({
2241
2239
  resultText,
2242
2240
  streamedText: session.pendingFinalText,
2243
2241
  failureNotice,
2242
+ silenced,
2244
2243
  });
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'
2244
+ const finalText = turnFinal.finalText;
2245
+ if (completedInput?.kind === 'canon') {
2246
+ await routeArtifactsForInput(completedInput, turnFinal.modelText);
2247
+ }
2248
+ const shouldDeliverFinal = finalText
2253
2249
  ? shouldDeliverClaudeFinal({
2254
2250
  turn: completedInput,
2255
2251
  finalizedTurnKeys: session.finalizedTurnKeys,
2256
2252
  interruptedTurnKeys: session.interruptedTurnKeys,
2257
2253
  })
2258
2254
  : false;
2255
+ // Silence drops the model's own words without sending them. Say so:
2256
+ // a suppressed final is invisible in the conversation, and the
2257
+ // failure notice that may follow it is Canon's line, not the
2258
+ // model's, so nothing else would record what was swallowed.
2259
+ if (turnFinal.speechSuppressed && turnFinal.modelText) {
2260
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
2261
+ + `Final not delivered (${turnFinal.modelText.length} chars): `
2262
+ + describeUndeliveredClaudeFinal({
2263
+ turn: completedInput,
2264
+ finalizedTurnKeys: session.finalizedTurnKeys,
2265
+ interruptedTurnKeys: session.interruptedTurnKeys,
2266
+ silencedTurnKeys: session.silencedTurnKeys,
2267
+ }));
2268
+ }
2259
2269
  // Gated finals are dropped without sending. That is correct for the
2260
2270
  // seed's own turn, but it is also how a misattributed Canon reply
2261
2271
  // disappears — silently, which is why this class of bug went unseen.
@@ -2267,7 +2277,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2267
2277
  turn: completedInput,
2268
2278
  finalizedTurnKeys: session.finalizedTurnKeys,
2269
2279
  interruptedTurnKeys: session.interruptedTurnKeys,
2270
- silencedTurnKeys: suppressFinal ? session.silencedTurnKeys : undefined,
2271
2280
  }));
2272
2281
  }
2273
2282
  const finalDelivered = finalText && shouldDeliverFinal
@@ -2298,7 +2307,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2298
2307
  if (shouldDeliverFinal && finalText) {
2299
2308
  scheduleFinalHandoffReset();
2300
2309
  }
2301
- else if (suppressFinal) {
2310
+ else if (turnFinal.speechSuppressed) {
2302
2311
  // Deliberate silence: blank the node (text '' AND an explicit
2303
2312
  // empty blocks array) before deleting it, or onStreamingCleared
2304
2313
  // 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.1",
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",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "0.3.220",
34
- "@canonmsg/agent-sdk": "^8.2.0",
35
- "@canonmsg/agent-tools": "^0.4.0",
36
- "@canonmsg/coding-agent-host": "^0.4.0",
37
- "@canonmsg/core": "^10.0.0",
34
+ "@canonmsg/agent-sdk": "^8.3.0",
35
+ "@canonmsg/agent-tools": "^0.5.0",
36
+ "@canonmsg/coding-agent-host": "^0.5.0",
37
+ "@canonmsg/core": "^10.2.0",
38
38
  "@canonmsg/rich-cards": "^0.9.1",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0"
40
40
  },