@canonmsg/claude-code-plugin 0.29.4 → 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.29.4",
4
+ "version": "0.31.0",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/README.md CHANGED
@@ -47,6 +47,7 @@ Public docs: <https://canonmail.com/agents/integrations>. Coding-host concepts:
47
47
  - **Interrupt** — Stop Claude mid-response from the app
48
48
  - **Context meter** — See context window usage in the app
49
49
  - **Max plan auth** — Uses your Claude subscription, no API key billing
50
+ - **Quiet group turns** — In groups the host shows the thinking indicator and the answer only; direct chats keep the live preview and margin activity (`--turn-verbosity`)
50
51
 
51
52
  ## Working directory
52
53
 
@@ -72,6 +73,24 @@ Current Canon truth for Claude host mode:
72
73
  - non-owner turns deny local filesystem, shell, web/network, task, MCP, and Canon outbound tools by default in first-party host mode; pure text replies still work, and owners can allow exact safe tool names with `CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS`
73
74
  - if worktree creation is unavailable for the selected project, Canon may fall back to shared-project execution and surface the fallback reason in session details
74
75
 
76
+ ## Turn verbosity
77
+
78
+ ```bash
79
+ canon-claude --cwd /path/to/project --turn-verbosity quiet
80
+ ```
81
+
82
+ `--turn-verbosity <verbose|quiet|auto>` controls how much of a turn's middle readers see. `CANON_TURN_VERBOSITY` is the environment fallback; the flag wins when both are set.
83
+
84
+ | Value | Effect |
85
+ |---|---|
86
+ | `auto` (default, same as unset) | Verbose in direct chats, quiet in groups |
87
+ | `verbose` | Live streaming text plus the margin activity rows on the final, everywhere |
88
+ | `quiet` | The thinking indicator and the answer, nothing in between, everywhere |
89
+
90
+ Quiet drops the live `/streaming` narration and the final's `turnTrail` activity rows. It does **not** drop the thinking indicator (which now stays up for the turn's whole working phase rather than handing over to a bubble that never appears; while the turn is parked on an approval the clients suppress an agent's dots and the header line carries the state), the turn state, the answer — including every part of a long chunked one — failure notices, generated files, or approval and question cards and their receipts.
91
+
92
+ This is an agent-developer setting. Canon never changes it, and it is deliberately not part of the per-conversation session config a user can edit. `canon-necromance` replays a stored launch command verbatim, so add the flag at registration time if you want a non-default value.
93
+
75
94
  ## Multiple agents
76
95
 
77
96
  ```bash
package/dist/host.d.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { type PermissionResult, type SDKControlReloadPluginsResponse } from '@anthropic-ai/claude-agent-sdk';
3
- import { type CanonRuntimeCommandDescriptor } from '@canonmsg/core';
3
+ import { type CanonRuntimeCommandDescriptor, type HostInboundParticipantContext as InboundParticipantContext, type CanonReplyContext, type MessageCreatedPayload, type ResolvedAgentBehaviorPolicy, type TurnVerbosityConfig } from '@canonmsg/core';
4
+ /**
5
+ * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
6
+ * per-conversation-type default".
7
+ *
8
+ * A value that parses to nothing is reported and then ignored rather than
9
+ * thrown on — this host's `parseArgs` is `strict: false`, so a typo'd flag
10
+ * already survives argv, and refusing to start over a presentation setting
11
+ * would take the agent offline for it. An empty declaration (`ENV=` in a plist
12
+ * or `--turn-verbosity ''`) is absence, not a mistake, so it says nothing.
13
+ */
14
+ export declare function resolveConfiguredClaudeTurnVerbosity(input: {
15
+ flag?: unknown;
16
+ env?: string | undefined;
17
+ onWarning?: (message: string) => void;
18
+ }): TurnVerbosityConfig | null;
4
19
  export declare function buildClaudePlanAllowResult(input: Record<string, unknown>): PermissionResult;
5
20
  /**
6
21
  * Claude Code's own slash commands (built-ins, plugins, project .claude/
@@ -9,4 +24,16 @@ export declare function buildClaudePlanAllowResult(input: Record<string, unknown
9
24
  * through. Canon-injected aliases keep precedence over native names.
10
25
  */
11
26
  export declare function buildNativeSlashCommandDescriptors(native: SDKControlReloadPluginsResponse['commands'], reservedAliases: ReadonlySet<string>): CanonRuntimeCommandDescriptor[];
27
+ export declare const NO_REPLY_TOOL_NAME: string;
28
+ export declare function buildCanonPrompt(input: {
29
+ content: string;
30
+ conversationId: string;
31
+ participantContext: InboundParticipantContext;
32
+ behavior?: ResolvedAgentBehaviorPolicy | null;
33
+ selfContexts?: MessageCreatedPayload['selfContexts'];
34
+ activeSelfContextId?: string | null;
35
+ provenance: NonNullable<MessageCreatedPayload['provenance']>;
36
+ replyContext?: CanonReplyContext | null;
37
+ message?: MessageCreatedPayload['message'];
38
+ }): string;
12
39
  export declare function main(): Promise<void>;
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, buildBoundedTurnTrail, 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, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, 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, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, prepareTurnTrailForDelivery, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, 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;
@@ -58,6 +58,12 @@ COMMON FLAGS
58
58
  Override a hidden detail field
59
59
  --hide-runtime-detail <field>
60
60
  Hide a runtime detail field
61
+ --turn-verbosity <verbose|quiet|auto>
62
+ How much of a turn's middle readers see. Default
63
+ (auto): verbose in direct chats, quiet in groups —
64
+ quiet shows the thinking indicator and the answer,
65
+ with no live narration and no margin activity rows.
66
+ Env: CANON_TURN_VERBOSITY
61
67
  --help, -h Show this help
62
68
  --version, -V Show package version
63
69
 
@@ -80,6 +86,37 @@ let workspaceRootMetadata = [];
80
86
  let runtimeModels = [];
81
87
  const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
82
88
  const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
89
+ /**
90
+ * Agent-developer setting, resolved once at startup. Deliberately NOT read from
91
+ * `/session-config`: that path is the USER's per-conversation control plane,
92
+ * and owner ruling 5 puts turn verbosity outside user control.
93
+ */
94
+ let configuredTurnVerbosity = null;
95
+ /**
96
+ * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
97
+ * per-conversation-type default".
98
+ *
99
+ * A value that parses to nothing is reported and then ignored rather than
100
+ * thrown on — this host's `parseArgs` is `strict: false`, so a typo'd flag
101
+ * already survives argv, and refusing to start over a presentation setting
102
+ * would take the agent offline for it. An empty declaration (`ENV=` in a plist
103
+ * or `--turn-verbosity ''`) is absence, not a mistake, so it says nothing.
104
+ */
105
+ export function resolveConfiguredClaudeTurnVerbosity(input) {
106
+ const sources = [
107
+ ['--turn-verbosity', typeof input.flag === 'string' ? input.flag : undefined],
108
+ ['CANON_TURN_VERBOSITY', input.env],
109
+ ];
110
+ for (const [name, raw] of sources) {
111
+ const parsed = parseTurnVerbosityConfig(raw);
112
+ if (parsed)
113
+ return parsed;
114
+ if (raw && raw.trim()) {
115
+ input.onWarning?.(`Ignoring ${name}=${JSON.stringify(raw)} — expected verbose, quiet or auto.`);
116
+ }
117
+ }
118
+ return null;
119
+ }
83
120
  let runtimeMetadata = {
84
121
  account: null,
85
122
  mcpServers: [],
@@ -699,7 +736,19 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
699
736
  supportsRequiresAction: true,
700
737
  supportsNonFinalPermanentMessages: false,
701
738
  };
702
- function buildCanonPrompt(input) {
739
+ /**
740
+ * The silence affordance as the model sees it here: the verbs are mounted as an
741
+ * in-process MCP server, so `no_reply` reaches the model namespaced. The group
742
+ * guidance in the shared frame names it; a runtime without the tool passes
743
+ * nothing and its cue drops the silence clause entirely.
744
+ *
745
+ * The verb segment is type-anchored: renaming the verb in `canon.verbs.v1` must
746
+ * fail the build here rather than leave every group turn pointing the model at
747
+ * a tool it does not have.
748
+ */
749
+ const NO_REPLY_VERB = 'no_reply';
750
+ export const NO_REPLY_TOOL_NAME = `mcp__${CANON_VERB_MCP_SERVER_NAME}__${NO_REPLY_VERB}`;
751
+ export function buildCanonPrompt(input) {
703
752
  return renderCodingHostInboundPrompt(buildCanonInboundFrameV1(buildCanonTurnContextV2({
704
753
  content: input.content,
705
754
  conversationId: input.conversationId,
@@ -710,13 +759,27 @@ function buildCanonPrompt(input) {
710
759
  provenance: input.provenance,
711
760
  replyContext: input.replyContext,
712
761
  message: input.message,
713
- })));
762
+ })), { noReplyToolName: NO_REPLY_TOOL_NAME });
714
763
  }
715
764
  function resolveArtifactRoutingMode(participantContext) {
716
765
  return participantContext.conversationType === 'direct' && participantContext.isOwner
717
766
  ? 'workspace-generated'
718
767
  : 'disabled';
719
768
  }
769
+ /**
770
+ * Everything about a turn that is decided from the message that started it, in
771
+ * one place. Both are resolved at the enqueue site and ride the envelope so a
772
+ * message that waits in the queue still runs under the answer it arrived with.
773
+ */
774
+ function resolveClaudeTurnModes(participantContext) {
775
+ return {
776
+ artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
777
+ turnVerbosity: resolveTurnVerbosity({
778
+ configured: configuredTurnVerbosity,
779
+ conversationType: participantContext.conversationType,
780
+ }),
781
+ };
782
+ }
720
783
  function renderInboundContent(message, materialized) {
721
784
  return renderCanonHostInboundContent(message, materialized);
722
785
  }
@@ -773,7 +836,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
773
836
  messageQueue.push(input);
774
837
  }
775
838
  };
776
- let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
839
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
777
840
  sendInput(createClaudeInputEnvelope({
778
841
  kind: 'canon',
779
842
  msg,
@@ -782,7 +845,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
782
845
  markAccepted,
783
846
  isOwnerTurn,
784
847
  requestingUserId,
785
- artifactRoutingMode,
848
+ ...turnModes,
786
849
  }));
787
850
  };
788
851
  // Envelopes pulled by the SDK, keyed by the uuid stamped on each message, so a
@@ -919,6 +982,12 @@ function createSession(conversationId, environment, agentId, client, typingSigna
919
982
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
920
983
  ...(routing.responseUserId ? { responseUserId: routing.responseUserId } : {}),
921
984
  };
985
+ }, (verb) => {
986
+ if (verb !== 'no_reply')
987
+ return;
988
+ const turnKey = session.activeInput?.turnKey;
989
+ if (turnKey)
990
+ session.silencedTurnKeys.add(turnKey);
922
991
  }),
923
992
  },
924
993
  },
@@ -1028,7 +1097,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1028
1097
  }
1029
1098
  // The SDK leaves plan mode when we allow ExitPlanMode, but it does
1030
1099
  // not notify the host, so reset the mirrored permission mode here so
1031
- // the composer turn-mode toggle and permission chip stop reporting
1100
+ // the permission chip and session surfaces stop reporting
1032
1101
  // 'plan'. Approve-only: on reject/revise the SDK stays in plan mode.
1033
1102
  if (session.state.permissionMode === 'plan') {
1034
1103
  session.state.permissionMode = 'default';
@@ -1108,7 +1177,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1108
1177
  environment,
1109
1178
  query: q,
1110
1179
  sendInput,
1111
- enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, artifactRoutingMode),
1180
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, turnModes),
1112
1181
  state: {
1113
1182
  model: undefined,
1114
1183
  permissionMode: config.permissionMode,
@@ -1117,6 +1186,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1117
1186
  state: 'idle',
1118
1187
  },
1119
1188
  availableModels: config.availableModels,
1189
+ // Corrected by the first turn that opens; a session with no turn publishes
1190
+ // nothing anyway.
1191
+ turnVerbosity: 'verbose',
1120
1192
  streamingText: '',
1121
1193
  streamingTimer: null,
1122
1194
  idleResetTimer: null,
@@ -1126,6 +1198,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1126
1198
  dispatchingInput: null,
1127
1199
  finalizedTurnKeys: new Set(),
1128
1200
  interruptedTurnKeys: new Set(),
1201
+ silencedTurnKeys: new Set(),
1129
1202
  pendingFinalText: null,
1130
1203
  pendingFinalDelivery: null,
1131
1204
  runtimeControlErrors: {},
@@ -1156,16 +1229,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1156
1229
  writeSnapshot: (snapshot) => {
1157
1230
  if (session.closed)
1158
1231
  return Promise.resolve();
1159
- const turnId = session.currentTurnId ?? snapshot.turnId;
1160
- session.streamingText = snapshot.text;
1161
- const blocks = snapshot.blocks?.map((block) => ({ ...block, turnId }));
1162
- return runtimeState.writeStreaming(conversationId, {
1163
- text: snapshot.text,
1164
- status: snapshot.status,
1165
- messageId: turnId,
1166
- turnId,
1167
- blocks,
1232
+ const write = planClaudeStreamingWrite({
1233
+ snapshot,
1234
+ turnId: session.currentTurnId ?? snapshot.turnId,
1235
+ turnVerbosity: session.turnVerbosity,
1168
1236
  });
1237
+ session.streamingText = write?.text ?? '';
1238
+ if (!write)
1239
+ return Promise.resolve();
1240
+ return runtimeState.writeStreaming(conversationId, write);
1169
1241
  },
1170
1242
  clearSnapshot: () => {
1171
1243
  session.streamingText = '';
@@ -1213,19 +1285,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1213
1285
  config.onInputCompleted?.(input.sourceMessageId);
1214
1286
  }
1215
1287
  /**
1216
- * The turn trail to attach to the final send.
1217
- *
1218
- * Filtering runs BEFORE bounding on purpose. `buildBoundedTurnTrail` walks
1219
- * blocks in sequence order and stops at the first one that would exceed its
1220
- * byte budget, and Claude's text blocks are the fattest entries — bounding
1221
- * first would spend the budget on blocks a chunked final then discards,
1222
- * leaving the margin with fewer tool blocks than it could have shown. With
1223
- * `willChunk` false this is exactly `getFinalTrail()`.
1288
+ * The turn trail to attach to the final send. Empty on a quiet turn — see
1289
+ * `claudeFinalTurnTrail`, which owns both that gate and the chunked-final
1290
+ * filter.
1224
1291
  */
1225
1292
  function getFinalTurnTrail(willChunk) {
1226
- const turnId = session.currentTurnId ?? `claude-${conversationId}`;
1227
- const blocks = prepareTurnTrailForDelivery(streamingOutput.getBlocks(), willChunk);
1228
- return buildBoundedTurnTrail(blocks).map((block) => ({ ...block, turnId }));
1293
+ return claudeFinalTurnTrail({
1294
+ blocks: streamingOutput.getBlocks(),
1295
+ turnId: session.currentTurnId ?? `claude-${conversationId}`,
1296
+ willChunk,
1297
+ turnVerbosity: session.turnVerbosity,
1298
+ });
1229
1299
  }
1230
1300
  function clearIdleResetTimer() {
1231
1301
  if (!session.idleResetTimer)
@@ -1530,37 +1600,32 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1530
1600
  metadata: buildArtifactMediaMetadata(),
1531
1601
  });
1532
1602
  }
1533
- async function routeWorkspaceGeneratedArtifacts(input) {
1534
- if (!input.artifactBaseline)
1535
- return;
1536
- try {
1537
- const result = await collectTurnArtifacts({
1538
- cwd: session.cwd,
1539
- baseline: input.artifactBaseline,
1540
- });
1541
- for (const file of result.files) {
1542
- try {
1543
- const { messageId } = await sendTurnArtifactFile(file);
1544
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Routed generated artifact ${file.relativePath ?? file.fileName} (${messageId})`);
1545
- }
1546
- catch (error) {
1547
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact upload failed for ${file.relativePath ?? file.fileName}: ${error instanceof Error ? error.message : String(error)}`);
1548
- }
1549
- }
1550
- for (const skipped of result.skipped) {
1551
- if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'scan-limit') {
1552
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact skipped ${skipped.fileName} (${skipped.reason})`);
1553
- }
1554
- }
1555
- }
1556
- catch (error) {
1557
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact routing failed:`, error instanceof Error ? error.message : error);
1558
- }
1559
- }
1560
- async function routeArtifactsForInput(input) {
1561
- if (input.artifactRoutingMode === 'workspace-generated') {
1562
- await routeWorkspaceGeneratedArtifacts(input);
1563
- }
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();
1564
1629
  }
1565
1630
  function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
1566
1631
  if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
@@ -1684,7 +1749,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1684
1749
  }
1685
1750
  runtimeState.writeTurnState(conversationId, {
1686
1751
  turnId: session.currentTurnId,
1687
- state: session.turnState,
1752
+ state: publishedClaudeTurnState(session.turnState, session.turnVerbosity),
1688
1753
  queueDepth: session.pendingInputs.length,
1689
1754
  currentSpeakerId: agentId,
1690
1755
  lastAcceptedIntent: session.lastAcceptedIntent,
@@ -1747,6 +1812,11 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1747
1812
  if (session.closed || !claudeInputOwnsTurnSlot(input))
1748
1813
  return;
1749
1814
  clearIdleResetTimer();
1815
+ // Adopted BEFORE the first publish of this turn, and left alone until the
1816
+ // next one opens: the seed, every delta, the final's trail and the
1817
+ // turn-state writes all read it, and a mid-turn change would make them
1818
+ // disagree with each other.
1819
+ session.turnVerbosity = input.turnVerbosity;
1750
1820
  openClaudeTurn(session);
1751
1821
  startVisibleWorkSignal();
1752
1822
  writeTurn();
@@ -1874,7 +1944,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1874
1944
  function hasInterruptibleSdkInput() {
1875
1945
  return session.activeInput !== null && session.dispatchingInput === null;
1876
1946
  }
1877
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
1947
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
1878
1948
  session.lastActivity = Date.now();
1879
1949
  const input = createClaudeInputEnvelope({
1880
1950
  kind: 'canon',
@@ -1884,7 +1954,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1884
1954
  markAccepted,
1885
1955
  isOwnerTurn,
1886
1956
  requestingUserId,
1887
- artifactRoutingMode,
1957
+ ...turnModes,
1888
1958
  });
1889
1959
  const decision = decideClaudeInboundDispatch({
1890
1960
  intent,
@@ -2035,7 +2105,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2035
2105
  else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
2036
2106
  session.toolInProgress = false;
2037
2107
  session.turnState = 'streaming';
2038
- stopVisibleWorkSignal();
2108
+ // The dots stop here only because the live bubble takes over as
2109
+ // the indicator. A quiet turn has no bubble, so stopping them
2110
+ // would leave the reader with nothing for the whole generation
2111
+ // phase — the longest stretch of the turn.
2112
+ if (shouldStopTypingDotsOnStreamedText(session.turnVerbosity)) {
2113
+ stopVisibleWorkSignal();
2114
+ }
2039
2115
  writeTurn();
2040
2116
  const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
2041
2117
  onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
@@ -2137,11 +2213,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2137
2213
  // working, at their existing accuracy.
2138
2214
  const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid) ?? session.activeInput;
2139
2215
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
2140
- // Turn artifacts land before the final text reply.
2141
- if (completedInput?.kind === 'canon'
2142
- && !session.interruptedTurnKeys.has(completedInput.turnKey)) {
2143
- await routeArtifactsForInput(completedInput);
2144
- }
2145
2216
  const resultText = typeof msg.result === 'string'
2146
2217
  ? msg.result.trim()
2147
2218
  : '';
@@ -2156,11 +2227,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2156
2227
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn failed (${msg.subtype}): `
2157
2228
  + (errorList.join(' | ') || 'no error detail'));
2158
2229
  }
2159
- 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({
2160
2236
  resultText,
2161
2237
  streamedText: session.pendingFinalText,
2162
2238
  failureNotice,
2239
+ silenced,
2163
2240
  });
2241
+ const finalText = turnFinal.finalText;
2242
+ if (completedInput?.kind === 'canon') {
2243
+ await routeArtifactsForInput(completedInput, turnFinal.modelText);
2244
+ }
2164
2245
  const shouldDeliverFinal = finalText
2165
2246
  ? shouldDeliverClaudeFinal({
2166
2247
  turn: completedInput,
@@ -2168,6 +2249,20 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2168
2249
  interruptedTurnKeys: session.interruptedTurnKeys,
2169
2250
  })
2170
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
+ }
2171
2266
  // Gated finals are dropped without sending. That is correct for the
2172
2267
  // seed's own turn, but it is also how a misattributed Canon reply
2173
2268
  // disappears — silently, which is why this class of bug went unseen.
@@ -2209,6 +2304,19 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2209
2304
  if (shouldDeliverFinal && finalText) {
2210
2305
  scheduleFinalHandoffReset();
2211
2306
  }
2307
+ else if (turnFinal.speechSuppressed) {
2308
+ // Deliberate silence: blank the node (text '' AND an explicit
2309
+ // empty blocks array) before deleting it, or onStreamingCleared
2310
+ // salvages this turn's narration into a durable bubble. Teardown
2311
+ // then rides the delivered-final handoff so the thinking
2312
+ // indicator ends exactly as it does for a real reply.
2313
+ await streamingOutput.blankAndClear().catch(() => { });
2314
+ // The live row is gone the moment the node is; without this the
2315
+ // handoff timer would leave typing dots behind it for 750 ms,
2316
+ // reading as "started to answer, then gave up".
2317
+ stopVisibleWorkSignal();
2318
+ scheduleFinalHandoffReset();
2319
+ }
2212
2320
  else {
2213
2321
  // Nothing durable is coming, so retire the live bubble here.
2214
2322
  // The delivering path does this after a handoff delay; without
@@ -2322,9 +2430,15 @@ export async function main() {
2322
2430
  'runtime-visibility': { type: 'string' },
2323
2431
  'show-runtime-detail': { type: 'string', multiple: true },
2324
2432
  'hide-runtime-detail': { type: 'string', multiple: true },
2433
+ 'turn-verbosity': { type: 'string' },
2325
2434
  },
2326
2435
  strict: false,
2327
2436
  });
2437
+ configuredTurnVerbosity = resolveConfiguredClaudeTurnVerbosity({
2438
+ flag: args['turn-verbosity'],
2439
+ env: process.env.CANON_TURN_VERBOSITY,
2440
+ onWarning: (message) => console.error(`[canon-host] ${message}`),
2441
+ });
2328
2442
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
2329
2443
  const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
2330
2444
  const configuredWorkspaceRoots = (args['workspace-root'] ?? []).filter((value) => typeof value === 'string');
@@ -3051,7 +3165,7 @@ export async function main() {
3051
3165
  promptText,
3052
3166
  materialized: [...replyMedia.materialized, ...materialized],
3053
3167
  });
3054
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
3168
+ const turnModes = resolveClaudeTurnModes(participantContext);
3055
3169
  session.enqueueInbound({
3056
3170
  type: 'user',
3057
3171
  message: {
@@ -3064,7 +3178,7 @@ export async function main() {
3064
3178
  senderId: m.senderId,
3065
3179
  senderName: m.senderName,
3066
3180
  }),
3067
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
3181
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3068
3182
  return 'queued';
3069
3183
  }
3070
3184
  const acceptedInboundMessageIds = new Set();
@@ -3288,7 +3402,7 @@ export async function main() {
3288
3402
  promptText,
3289
3403
  materialized: [...replyMedia.materialized, ...materialized],
3290
3404
  });
3291
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
3405
+ const turnModes = resolveClaudeTurnModes(participantContext);
3292
3406
  session.enqueueInbound({
3293
3407
  type: 'user',
3294
3408
  message: {
@@ -3301,7 +3415,7 @@ export async function main() {
3301
3415
  senderId: m.senderId,
3302
3416
  senderName: m.senderName,
3303
3417
  }),
3304
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
3418
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3305
3419
  return true;
3306
3420
  })().then((queued) => {
3307
3421
  if (!queued)
package/dist/server.js CHANGED
@@ -171,6 +171,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
171
171
  },
172
172
  },
173
173
  // Canonical verbs — projections of canon.verbs.v1 (@canonmsg/agent-tools).
174
+ // Channel mode owns no turn, so `no_reply` is an ack-only no-op here: the
175
+ // standalone session has no final delivery for it to suppress.
174
176
  ...canonVerbToolDefinitions(),
175
177
  ],
176
178
  }));
@@ -1,6 +1,6 @@
1
1
  import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
- import type { DeliveryIntent, ModelOption, TurnLifecycleState, TurnOutputBlock } from '@canonmsg/core';
3
- import type { TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
2
+ import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
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 {
@@ -12,11 +12,26 @@ export interface ClaudeInputEnvelope {
12
12
  sourceMessageId: string | null;
13
13
  markAccepted: boolean;
14
14
  artifactRoutingMode: ClaudeArtifactRoutingMode;
15
+ /**
16
+ * How loud this turn is, resolved when the message arrived and carried here
17
+ * so the whole turn — including a resumed final delivery — runs under one
18
+ * answer. The session re-affirms it at `openTurn`, which is what corrects a
19
+ * session created while a conversation fetch was failing.
20
+ */
21
+ turnVerbosity: TurnVerbosity;
15
22
  artifactBaseline: TurnArtifactSnapshot | null;
16
23
  turnKey: string;
17
24
  isOwnerTurn: boolean;
18
25
  requestingUserId: string | null;
19
26
  }
27
+ /**
28
+ * The per-turn modes an inbound message resolves from its participant context,
29
+ * grouped so they travel together from the enqueue site onto the envelope.
30
+ */
31
+ export interface ClaudeTurnModes {
32
+ artifactRoutingMode?: ClaudeArtifactRoutingMode;
33
+ turnVerbosity?: TurnVerbosity;
34
+ }
20
35
  export interface ClaudeRuntimeControlError {
21
36
  value: string;
22
37
  message: string;
@@ -83,6 +98,7 @@ export declare function createClaudeInputEnvelope(input: {
83
98
  sourceMessageId?: string | null;
84
99
  markAccepted?: boolean;
85
100
  artifactRoutingMode?: ClaudeArtifactRoutingMode;
101
+ turnVerbosity?: TurnVerbosity;
86
102
  artifactBaseline?: TurnArtifactSnapshot | null;
87
103
  isOwnerTurn?: boolean;
88
104
  requestingUserId?: string | null;
@@ -102,6 +118,23 @@ export declare function shouldDeliverClaudeFinal(input: {
102
118
  finalizedTurnKeys: ReadonlySet<string>;
103
119
  interruptedTurnKeys: ReadonlySet<string>;
104
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;
105
138
  export declare function rememberDispatchedClaudeInput(dispatched: Map<string, ClaudeInputEnvelope>, input: ClaudeInputEnvelope): void;
106
139
  /**
107
140
  * The envelope a turn result belongs to, matched by `user_message_uuid`.
@@ -123,6 +156,7 @@ export declare function describeUndeliveredClaudeFinal(input: {
123
156
  turn: ClaudeInputEnvelope | null;
124
157
  finalizedTurnKeys: ReadonlySet<string>;
125
158
  interruptedTurnKeys: ReadonlySet<string>;
159
+ silencedTurnKeys?: ReadonlySet<string>;
126
160
  }): string;
127
161
  export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
128
162
  export interface ClaudeTurnSlotState {
@@ -480,6 +514,41 @@ export declare function composeClaudeFinalText(input: {
480
514
  streamedText?: string | null;
481
515
  failureNotice?: string | null;
482
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;
483
552
  export type ClaudeFinalDeliveryFailure = 'retry' | 'permanent';
484
553
  /**
485
554
  * How the host should react to a failed final-reply send.
@@ -550,6 +619,76 @@ export declare function claudeFinalWillChunk(finalText: string, maxTextBytes?: n
550
619
  * which keeps the common case byte-identical to before.
551
620
  */
552
621
  export declare function prepareTurnTrailForDelivery(trail: ReadonlyArray<TurnOutputBlock>, willChunk: boolean): TurnOutputBlock[];
622
+ /**
623
+ * The trail to hang on this turn's final.
624
+ *
625
+ * The quiet gate lives HERE rather than in the controller because the two are
626
+ * genuinely separate decisions: a quiet turn runs its controller in `'status'`
627
+ * mode, which drops the live writes but keeps accumulating blocks in memory —
628
+ * so `getBlocks()` still returns a full trail for a turn the reader watched in
629
+ * silence. Forgetting this second gate is the one way quiet mode half-ships.
630
+ *
631
+ * Filtering runs BEFORE bounding, as it did inline: `buildBoundedTurnTrail`
632
+ * stops at the first block that would exceed its byte budget, and Claude's text
633
+ * blocks are the fattest entries — bounding first would spend the budget on
634
+ * blocks a chunked final then discards.
635
+ */
636
+ export declare function claudeFinalTurnTrail(input: {
637
+ blocks: ReadonlyArray<TurnOutputBlock>;
638
+ turnId: string;
639
+ willChunk: boolean;
640
+ turnVerbosity: TurnVerbosity;
641
+ }): TurnOutputBlock[];
642
+ /**
643
+ * The turn state a quiet turn publishes to `/turn-state`.
644
+ *
645
+ * Current clients render "is thinking" for every open non-waiting state, so
646
+ * the chat header is unchanged. It exists for app binaries older than #602,
647
+ * whose `filterVisibleTypingUsers` suppressed an agent's typing dots whenever
648
+ * its turn state was `streaming` or `tool` — on the assumption that a live
649
+ * bubble was carrying the state instead. A quiet turn has no bubble, so such a
650
+ * binary would show nothing at all for the back half of the turn. Staying on
651
+ * `thinking` keeps its dots.
652
+ *
653
+ * One current consumer does read the difference: the direct-chat session strip
654
+ * (`buildAgentSessionPresentation`, which early-returns for group chats) labels
655
+ * turn state `streaming` "Streaming" / "Live preview" and `thinking`
656
+ * "Thinking". So a direct chat started with an explicit `--turn-verbosity
657
+ * quiet` reads "Thinking" for the whole turn — which is what a turn publishing
658
+ * no live preview actually is.
659
+ *
660
+ * `waiting_input` is deliberately NOT folded in: it is the one state that
661
+ * changes what the header says and how the clients treat the dots, and a turn
662
+ * blocked on a human is not thinking.
663
+ */
664
+ export declare function publishedClaudeTurnState(state: TurnLifecycleState, turnVerbosity: TurnVerbosity): TurnLifecycleState;
665
+ /**
666
+ * The `/streaming` write for one snapshot, or `null` for none.
667
+ *
668
+ * Extracted from the session's `writeSnapshot` so the quiet gate is a unit a
669
+ * test can hold rather than one inline comparison a refactor can drop in
670
+ * silence. The gate cannot live in the controller's `mode`: the controller is
671
+ * per-SESSION here while verbosity is per-TURN.
672
+ *
673
+ * A quiet turn publishes no node at all rather than a status-only one. Writing
674
+ * then deleting would hand `onStreamingCleared` something to salvage into a
675
+ * durable bubble the reader was never meant to see; never writing leaves the
676
+ * delete with nothing to fire on.
677
+ */
678
+ export declare function planClaudeStreamingWrite(input: {
679
+ snapshot: TurnOutputSnapshot;
680
+ turnId: string;
681
+ turnVerbosity: TurnVerbosity;
682
+ }): RuntimeStreamingPayload | null;
683
+ /**
684
+ * When streamed text starts arriving, do the typing dots retire?
685
+ *
686
+ * In verbose they do, and should: the live bubble becomes the indicator, and
687
+ * two indicators for one turn is noise. A quiet turn has no bubble, so the
688
+ * dots are the only thing the reader has for the whole generation phase — the
689
+ * longest stretch of the turn. Spelled the same way on the Codex host.
690
+ */
691
+ export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
553
692
  export declare const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated \u2014 the full answer exceeded Canon's message size limit]_";
554
693
  /**
555
694
  * Last-resort degradation for a final Canon refuses to accept: keep as much of
@@ -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 { DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, 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
+ 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();
@@ -17,6 +17,10 @@ export function createClaudeInputEnvelope(input) {
17
17
  sourceMessageId,
18
18
  markAccepted: Boolean(input.markAccepted),
19
19
  artifactRoutingMode: input.artifactRoutingMode ?? 'disabled',
20
+ // Verbose is the safe default for an envelope nobody resolved (the internal
21
+ // `seed` kind, and any caller that predates the flag): quiet is a decision,
22
+ // never an accident.
23
+ turnVerbosity: input.turnVerbosity ?? 'verbose',
20
24
  artifactBaseline: input.artifactBaseline ?? null,
21
25
  isOwnerTurn: input.kind === 'canon'
22
26
  ? input.isOwnerTurn === true
@@ -56,6 +60,27 @@ export function shouldDeliverClaudeFinal(input) {
56
60
  return false;
57
61
  return true;
58
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
+ }
59
84
  /** Bounds the correlation map if the SDK ever stops reporting a uuid we sent. */
60
85
  const MAX_TRACKED_DISPATCHED_INPUTS = 64;
61
86
  export function rememberDispatchedClaudeInput(dispatched, input) {
@@ -101,6 +126,10 @@ export function claudeInputOwnsTurnSlot(input) {
101
126
  export function describeUndeliveredClaudeFinal(input) {
102
127
  if (!input.turn)
103
128
  return 'no Canon turn owns this result';
129
+ // Checked first: callers pass this set only when silence actually gated the
130
+ // turn, and when it did it IS the reason, whatever else is true of the turn.
131
+ if (input.silencedTurnKeys?.has(input.turn.turnKey))
132
+ return 'turn chose no_reply';
104
133
  if (input.turn.kind !== 'canon')
105
134
  return `owning turn is '${input.turn.kind}'`;
106
135
  if (input.interruptedTurnKeys.has(input.turn.turnKey))
@@ -769,6 +798,45 @@ export function composeClaudeFinalText(input) {
769
798
  return primary;
770
799
  return primary ? `${primary}\n\n${input.failureNotice}` : input.failureNotice;
771
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
+ }
772
840
  /**
773
841
  * How the host should react to a failed final-reply send.
774
842
  *
@@ -865,6 +933,90 @@ export function prepareTurnTrailForDelivery(trail, willChunk) {
865
933
  return [...trail];
866
934
  return trail.filter((block) => block.kind !== 'text');
867
935
  }
936
+ /**
937
+ * The trail to hang on this turn's final.
938
+ *
939
+ * The quiet gate lives HERE rather than in the controller because the two are
940
+ * genuinely separate decisions: a quiet turn runs its controller in `'status'`
941
+ * mode, which drops the live writes but keeps accumulating blocks in memory —
942
+ * so `getBlocks()` still returns a full trail for a turn the reader watched in
943
+ * silence. Forgetting this second gate is the one way quiet mode half-ships.
944
+ *
945
+ * Filtering runs BEFORE bounding, as it did inline: `buildBoundedTurnTrail`
946
+ * stops at the first block that would exceed its byte budget, and Claude's text
947
+ * blocks are the fattest entries — bounding first would spend the budget on
948
+ * blocks a chunked final then discards.
949
+ */
950
+ export function claudeFinalTurnTrail(input) {
951
+ if (!shouldPublishTurnTrail(input.turnVerbosity))
952
+ return [];
953
+ const filtered = prepareTurnTrailForDelivery(input.blocks, input.willChunk);
954
+ return buildBoundedTurnTrail(filtered).map((block) => ({ ...block, turnId: input.turnId }));
955
+ }
956
+ /**
957
+ * The turn state a quiet turn publishes to `/turn-state`.
958
+ *
959
+ * Current clients render "is thinking" for every open non-waiting state, so
960
+ * the chat header is unchanged. It exists for app binaries older than #602,
961
+ * whose `filterVisibleTypingUsers` suppressed an agent's typing dots whenever
962
+ * its turn state was `streaming` or `tool` — on the assumption that a live
963
+ * bubble was carrying the state instead. A quiet turn has no bubble, so such a
964
+ * binary would show nothing at all for the back half of the turn. Staying on
965
+ * `thinking` keeps its dots.
966
+ *
967
+ * One current consumer does read the difference: the direct-chat session strip
968
+ * (`buildAgentSessionPresentation`, which early-returns for group chats) labels
969
+ * turn state `streaming` "Streaming" / "Live preview" and `thinking`
970
+ * "Thinking". So a direct chat started with an explicit `--turn-verbosity
971
+ * quiet` reads "Thinking" for the whole turn — which is what a turn publishing
972
+ * no live preview actually is.
973
+ *
974
+ * `waiting_input` is deliberately NOT folded in: it is the one state that
975
+ * changes what the header says and how the clients treat the dots, and a turn
976
+ * blocked on a human is not thinking.
977
+ */
978
+ export function publishedClaudeTurnState(state, turnVerbosity) {
979
+ if (turnVerbosity !== 'quiet')
980
+ return state;
981
+ return state === 'streaming' || state === 'tool' ? 'thinking' : state;
982
+ }
983
+ /**
984
+ * The `/streaming` write for one snapshot, or `null` for none.
985
+ *
986
+ * Extracted from the session's `writeSnapshot` so the quiet gate is a unit a
987
+ * test can hold rather than one inline comparison a refactor can drop in
988
+ * silence. The gate cannot live in the controller's `mode`: the controller is
989
+ * per-SESSION here while verbosity is per-TURN.
990
+ *
991
+ * A quiet turn publishes no node at all rather than a status-only one. Writing
992
+ * then deleting would hand `onStreamingCleared` something to salvage into a
993
+ * durable bubble the reader was never meant to see; never writing leaves the
994
+ * delete with nothing to fire on.
995
+ */
996
+ export function planClaudeStreamingWrite(input) {
997
+ if (input.turnVerbosity === 'quiet')
998
+ return null;
999
+ return {
1000
+ text: input.snapshot.text,
1001
+ status: input.snapshot.status,
1002
+ messageId: input.turnId,
1003
+ turnId: input.turnId,
1004
+ ...(input.snapshot.blocks
1005
+ ? { blocks: input.snapshot.blocks.map((block) => ({ ...block, turnId: input.turnId })) }
1006
+ : {}),
1007
+ };
1008
+ }
1009
+ /**
1010
+ * When streamed text starts arriving, do the typing dots retire?
1011
+ *
1012
+ * In verbose they do, and should: the live bubble becomes the indicator, and
1013
+ * two indicators for one turn is noise. A quiet turn has no bubble, so the
1014
+ * dots are the only thing the reader has for the whole generation phase — the
1015
+ * longest stretch of the turn. Spelled the same way on the Codex host.
1016
+ */
1017
+ export function shouldStopTypingDotsOnStreamedText(turnVerbosity) {
1018
+ return turnVerbosity !== 'quiet';
1019
+ }
868
1020
  export const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated — the full answer exceeded Canon's message size limit]_";
869
1021
  /** How far back from the cut a paragraph break is still worth cutting at. */
870
1022
  const CLAUDE_TRUNCATION_BOUNDARY_WINDOW_CHARS = 400;
@@ -63,6 +63,9 @@ const CANON_HITL_OR_READ_VERBS = new Set([
63
63
  'list_contacts',
64
64
  'list_contact_requests',
65
65
  'list_conversations',
66
+ // Staying quiet posts nothing; an approval card asking permission for
67
+ // silence would gate a no-op behind a human decision.
68
+ 'no_reply',
66
69
  ]);
67
70
  // Asking permission to create or poll an interaction would put the interaction
68
71
  // behind another interaction and can recurse indefinitely. Fail closed instead.
@@ -86,6 +89,9 @@ const CANON_NON_OWNER_ALLOWED_VERBS = new Set([
86
89
  'check_approval',
87
90
  'send_card',
88
91
  'request_card',
92
+ // The group turn is exactly where silence matters, and group turns are
93
+ // usually non-owner turns.
94
+ 'no_reply',
89
95
  ]);
90
96
  export function canonVerbFromToolName(toolName) {
91
97
  const normalized = normalizeToolName(toolName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.29.4",
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",
@@ -31,11 +31,11 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "0.3.220",
34
- "@canonmsg/agent-sdk": "^8.1.0",
35
- "@canonmsg/agent-tools": "^0.3.4",
36
- "@canonmsg/coding-agent-host": "^0.4.0",
37
- "@canonmsg/core": "^9.2.0",
38
- "@canonmsg/rich-cards": "^0.9.0",
34
+ "@canonmsg/agent-sdk": "^8.2.0",
35
+ "@canonmsg/agent-tools": "^0.4.0",
36
+ "@canonmsg/coding-agent-host": "^0.5.0",
37
+ "@canonmsg/core": "^10.1.0",
38
+ "@canonmsg/rich-cards": "^0.9.1",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0"
40
40
  },
41
41
  "engines": {