@canonmsg/claude-code-plugin 0.29.3 → 0.30.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.3",
4
+ "version": "0.30.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
@@ -28,12 +28,12 @@ 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
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';
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';
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, composeClaudeFinalText, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, 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)
@@ -1684,7 +1754,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1684
1754
  }
1685
1755
  runtimeState.writeTurnState(conversationId, {
1686
1756
  turnId: session.currentTurnId,
1687
- state: session.turnState,
1757
+ state: publishedClaudeTurnState(session.turnState, session.turnVerbosity),
1688
1758
  queueDepth: session.pendingInputs.length,
1689
1759
  currentSpeakerId: agentId,
1690
1760
  lastAcceptedIntent: session.lastAcceptedIntent,
@@ -1747,6 +1817,11 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1747
1817
  if (session.closed || !claudeInputOwnsTurnSlot(input))
1748
1818
  return;
1749
1819
  clearIdleResetTimer();
1820
+ // Adopted BEFORE the first publish of this turn, and left alone until the
1821
+ // next one opens: the seed, every delta, the final's trail and the
1822
+ // turn-state writes all read it, and a mid-turn change would make them
1823
+ // disagree with each other.
1824
+ session.turnVerbosity = input.turnVerbosity;
1750
1825
  openClaudeTurn(session);
1751
1826
  startVisibleWorkSignal();
1752
1827
  writeTurn();
@@ -1874,7 +1949,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1874
1949
  function hasInterruptibleSdkInput() {
1875
1950
  return session.activeInput !== null && session.dispatchingInput === null;
1876
1951
  }
1877
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
1952
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
1878
1953
  session.lastActivity = Date.now();
1879
1954
  const input = createClaudeInputEnvelope({
1880
1955
  kind: 'canon',
@@ -1884,7 +1959,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1884
1959
  markAccepted,
1885
1960
  isOwnerTurn,
1886
1961
  requestingUserId,
1887
- artifactRoutingMode,
1962
+ ...turnModes,
1888
1963
  });
1889
1964
  const decision = decideClaudeInboundDispatch({
1890
1965
  intent,
@@ -2035,7 +2110,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2035
2110
  else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
2036
2111
  session.toolInProgress = false;
2037
2112
  session.turnState = 'streaming';
2038
- stopVisibleWorkSignal();
2113
+ // The dots stop here only because the live bubble takes over as
2114
+ // the indicator. A quiet turn has no bubble, so stopping them
2115
+ // would leave the reader with nothing for the whole generation
2116
+ // phase — the longest stretch of the turn.
2117
+ if (shouldStopTypingDotsOnStreamedText(session.turnVerbosity)) {
2118
+ stopVisibleWorkSignal();
2119
+ }
2039
2120
  writeTurn();
2040
2121
  const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
2041
2122
  onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
@@ -2161,7 +2242,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2161
2242
  streamedText: session.pendingFinalText,
2162
2243
  failureNotice,
2163
2244
  });
2164
- const shouldDeliverFinal = finalText
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'
2165
2253
  ? shouldDeliverClaudeFinal({
2166
2254
  turn: completedInput,
2167
2255
  finalizedTurnKeys: session.finalizedTurnKeys,
@@ -2179,6 +2267,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2179
2267
  turn: completedInput,
2180
2268
  finalizedTurnKeys: session.finalizedTurnKeys,
2181
2269
  interruptedTurnKeys: session.interruptedTurnKeys,
2270
+ silencedTurnKeys: suppressFinal ? session.silencedTurnKeys : undefined,
2182
2271
  }));
2183
2272
  }
2184
2273
  const finalDelivered = finalText && shouldDeliverFinal
@@ -2209,6 +2298,19 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2209
2298
  if (shouldDeliverFinal && finalText) {
2210
2299
  scheduleFinalHandoffReset();
2211
2300
  }
2301
+ else if (suppressFinal) {
2302
+ // Deliberate silence: blank the node (text '' AND an explicit
2303
+ // empty blocks array) before deleting it, or onStreamingCleared
2304
+ // salvages this turn's narration into a durable bubble. Teardown
2305
+ // then rides the delivered-final handoff so the thinking
2306
+ // indicator ends exactly as it does for a real reply.
2307
+ await streamingOutput.blankAndClear().catch(() => { });
2308
+ // The live row is gone the moment the node is; without this the
2309
+ // handoff timer would leave typing dots behind it for 750 ms,
2310
+ // reading as "started to answer, then gave up".
2311
+ stopVisibleWorkSignal();
2312
+ scheduleFinalHandoffReset();
2313
+ }
2212
2314
  else {
2213
2315
  // Nothing durable is coming, so retire the live bubble here.
2214
2316
  // The delivering path does this after a handoff delay; without
@@ -2322,9 +2424,15 @@ export async function main() {
2322
2424
  'runtime-visibility': { type: 'string' },
2323
2425
  'show-runtime-detail': { type: 'string', multiple: true },
2324
2426
  'hide-runtime-detail': { type: 'string', multiple: true },
2427
+ 'turn-verbosity': { type: 'string' },
2325
2428
  },
2326
2429
  strict: false,
2327
2430
  });
2431
+ configuredTurnVerbosity = resolveConfiguredClaudeTurnVerbosity({
2432
+ flag: args['turn-verbosity'],
2433
+ env: process.env.CANON_TURN_VERBOSITY,
2434
+ onWarning: (message) => console.error(`[canon-host] ${message}`),
2435
+ });
2328
2436
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
2329
2437
  const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
2330
2438
  const configuredWorkspaceRoots = (args['workspace-root'] ?? []).filter((value) => typeof value === 'string');
@@ -3051,7 +3159,7 @@ export async function main() {
3051
3159
  promptText,
3052
3160
  materialized: [...replyMedia.materialized, ...materialized],
3053
3161
  });
3054
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
3162
+ const turnModes = resolveClaudeTurnModes(participantContext);
3055
3163
  session.enqueueInbound({
3056
3164
  type: 'user',
3057
3165
  message: {
@@ -3064,7 +3172,7 @@ export async function main() {
3064
3172
  senderId: m.senderId,
3065
3173
  senderName: m.senderName,
3066
3174
  }),
3067
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
3175
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3068
3176
  return 'queued';
3069
3177
  }
3070
3178
  const acceptedInboundMessageIds = new Set();
@@ -3288,7 +3396,7 @@ export async function main() {
3288
3396
  promptText,
3289
3397
  materialized: [...replyMedia.materialized, ...materialized],
3290
3398
  });
3291
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
3399
+ const turnModes = resolveClaudeTurnModes(participantContext);
3292
3400
  session.enqueueInbound({
3293
3401
  type: 'user',
3294
3402
  message: {
@@ -3301,7 +3409,7 @@ export async function main() {
3301
3409
  senderId: m.senderId,
3302
3410
  senderName: m.senderName,
3303
3411
  }),
3304
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
3412
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
3305
3413
  return true;
3306
3414
  })().then((queued) => {
3307
3415
  if (!queued)
package/dist/register.js CHANGED
@@ -21,7 +21,7 @@ REQUIRED
21
21
 
22
22
  FLAGS
23
23
  --profile <name> Local profile name in ~/.canon/agents.json
24
- --environment <id> Canon environment ID (or CANON_ENVIRONMENT_ID)
24
+ --environment <id> Canon environment ID (or CANON_ENVIRONMENT_ID; default: canon-prod-v1)
25
25
  --base-url <url> Canon API base URL override
26
26
  --stream-url <url> Canon stream URL override
27
27
  --rtdb-url <url> Canon RTDB URL override
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,5 +1,5 @@
1
1
  import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
- import type { DeliveryIntent, ModelOption, TurnLifecycleState, TurnOutputBlock } from '@canonmsg/core';
2
+ import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
3
3
  import type { TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
4
4
  export type ClaudeInputKind = 'seed' | 'canon';
5
5
  export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
@@ -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;
@@ -123,6 +139,7 @@ export declare function describeUndeliveredClaudeFinal(input: {
123
139
  turn: ClaudeInputEnvelope | null;
124
140
  finalizedTurnKeys: ReadonlySet<string>;
125
141
  interruptedTurnKeys: ReadonlySet<string>;
142
+ silencedTurnKeys?: ReadonlySet<string>;
126
143
  }): string;
127
144
  export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
128
145
  export interface ClaudeTurnSlotState {
@@ -550,6 +567,76 @@ export declare function claudeFinalWillChunk(finalText: string, maxTextBytes?: n
550
567
  * which keeps the common case byte-identical to before.
551
568
  */
552
569
  export declare function prepareTurnTrailForDelivery(trail: ReadonlyArray<TurnOutputBlock>, willChunk: boolean): TurnOutputBlock[];
570
+ /**
571
+ * The trail to hang on this turn's final.
572
+ *
573
+ * The quiet gate lives HERE rather than in the controller because the two are
574
+ * genuinely separate decisions: a quiet turn runs its controller in `'status'`
575
+ * mode, which drops the live writes but keeps accumulating blocks in memory —
576
+ * so `getBlocks()` still returns a full trail for a turn the reader watched in
577
+ * silence. Forgetting this second gate is the one way quiet mode half-ships.
578
+ *
579
+ * Filtering runs BEFORE bounding, as it did inline: `buildBoundedTurnTrail`
580
+ * stops at the first block that would exceed its byte budget, and Claude's text
581
+ * blocks are the fattest entries — bounding first would spend the budget on
582
+ * blocks a chunked final then discards.
583
+ */
584
+ export declare function claudeFinalTurnTrail(input: {
585
+ blocks: ReadonlyArray<TurnOutputBlock>;
586
+ turnId: string;
587
+ willChunk: boolean;
588
+ turnVerbosity: TurnVerbosity;
589
+ }): TurnOutputBlock[];
590
+ /**
591
+ * The turn state a quiet turn publishes to `/turn-state`.
592
+ *
593
+ * Current clients render "is thinking" for every open non-waiting state, so
594
+ * the chat header is unchanged. It exists for app binaries older than #602,
595
+ * whose `filterVisibleTypingUsers` suppressed an agent's typing dots whenever
596
+ * its turn state was `streaming` or `tool` — on the assumption that a live
597
+ * bubble was carrying the state instead. A quiet turn has no bubble, so such a
598
+ * binary would show nothing at all for the back half of the turn. Staying on
599
+ * `thinking` keeps its dots.
600
+ *
601
+ * One current consumer does read the difference: the direct-chat session strip
602
+ * (`buildAgentSessionPresentation`, which early-returns for group chats) labels
603
+ * turn state `streaming` "Streaming" / "Live preview" and `thinking`
604
+ * "Thinking". So a direct chat started with an explicit `--turn-verbosity
605
+ * quiet` reads "Thinking" for the whole turn — which is what a turn publishing
606
+ * no live preview actually is.
607
+ *
608
+ * `waiting_input` is deliberately NOT folded in: it is the one state that
609
+ * changes what the header says and how the clients treat the dots, and a turn
610
+ * blocked on a human is not thinking.
611
+ */
612
+ export declare function publishedClaudeTurnState(state: TurnLifecycleState, turnVerbosity: TurnVerbosity): TurnLifecycleState;
613
+ /**
614
+ * The `/streaming` write for one snapshot, or `null` for none.
615
+ *
616
+ * Extracted from the session's `writeSnapshot` so the quiet gate is a unit a
617
+ * test can hold rather than one inline comparison a refactor can drop in
618
+ * silence. The gate cannot live in the controller's `mode`: the controller is
619
+ * per-SESSION here while verbosity is per-TURN.
620
+ *
621
+ * A quiet turn publishes no node at all rather than a status-only one. Writing
622
+ * then deleting would hand `onStreamingCleared` something to salvage into a
623
+ * durable bubble the reader was never meant to see; never writing leaves the
624
+ * delete with nothing to fire on.
625
+ */
626
+ export declare function planClaudeStreamingWrite(input: {
627
+ snapshot: TurnOutputSnapshot;
628
+ turnId: string;
629
+ turnVerbosity: TurnVerbosity;
630
+ }): RuntimeStreamingPayload | null;
631
+ /**
632
+ * When streamed text starts arriving, do the typing dots retire?
633
+ *
634
+ * In verbose they do, and should: the live bubble becomes the indicator, and
635
+ * two indicators for one turn is noise. A quiet turn has no bubble, so the
636
+ * dots are the only thing the reader has for the whole generation phase — the
637
+ * longest stretch of the turn. Spelled the same way on the Codex host.
638
+ */
639
+ export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
553
640
  export declare const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated \u2014 the full answer exceeded Canon's message size limit]_";
554
641
  /**
555
642
  * Last-resort degradation for a final Canon refuses to accept: keep as much of
@@ -1,6 +1,6 @@
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';
3
+ import { buildBoundedTurnTrail, DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isRetryableCanonDeliveryError, shouldPublishTurnTrail, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
4
4
  import { boundTrailBlockMap, buildTrailBlockId, buildUndeliverableFinalNotice as buildHostUndeliverableFinalNotice, normalizePlanStepStatus, normalizeTrailKey, PLAN_BLOCK_TITLE, renderPlanSteps, truncateFailureDetail, } from '@canonmsg/coding-agent-host';
5
5
  export function createClaudeInputEnvelope(input) {
6
6
  const sourceMessageId = input.sourceMessageId ?? null;
@@ -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
@@ -101,6 +105,10 @@ export function claudeInputOwnsTurnSlot(input) {
101
105
  export function describeUndeliveredClaudeFinal(input) {
102
106
  if (!input.turn)
103
107
  return 'no Canon turn owns this result';
108
+ // Checked first: callers pass this set only when silence actually gated the
109
+ // turn, and when it did it IS the reason, whatever else is true of the turn.
110
+ if (input.silencedTurnKeys?.has(input.turn.turnKey))
111
+ return 'turn chose no_reply';
104
112
  if (input.turn.kind !== 'canon')
105
113
  return `owning turn is '${input.turn.kind}'`;
106
114
  if (input.interruptedTurnKeys.has(input.turn.turnKey))
@@ -865,6 +873,90 @@ export function prepareTurnTrailForDelivery(trail, willChunk) {
865
873
  return [...trail];
866
874
  return trail.filter((block) => block.kind !== 'text');
867
875
  }
876
+ /**
877
+ * The trail to hang on this turn's final.
878
+ *
879
+ * The quiet gate lives HERE rather than in the controller because the two are
880
+ * genuinely separate decisions: a quiet turn runs its controller in `'status'`
881
+ * mode, which drops the live writes but keeps accumulating blocks in memory —
882
+ * so `getBlocks()` still returns a full trail for a turn the reader watched in
883
+ * silence. Forgetting this second gate is the one way quiet mode half-ships.
884
+ *
885
+ * Filtering runs BEFORE bounding, as it did inline: `buildBoundedTurnTrail`
886
+ * stops at the first block that would exceed its byte budget, and Claude's text
887
+ * blocks are the fattest entries — bounding first would spend the budget on
888
+ * blocks a chunked final then discards.
889
+ */
890
+ export function claudeFinalTurnTrail(input) {
891
+ if (!shouldPublishTurnTrail(input.turnVerbosity))
892
+ return [];
893
+ const filtered = prepareTurnTrailForDelivery(input.blocks, input.willChunk);
894
+ return buildBoundedTurnTrail(filtered).map((block) => ({ ...block, turnId: input.turnId }));
895
+ }
896
+ /**
897
+ * The turn state a quiet turn publishes to `/turn-state`.
898
+ *
899
+ * Current clients render "is thinking" for every open non-waiting state, so
900
+ * the chat header is unchanged. It exists for app binaries older than #602,
901
+ * whose `filterVisibleTypingUsers` suppressed an agent's typing dots whenever
902
+ * its turn state was `streaming` or `tool` — on the assumption that a live
903
+ * bubble was carrying the state instead. A quiet turn has no bubble, so such a
904
+ * binary would show nothing at all for the back half of the turn. Staying on
905
+ * `thinking` keeps its dots.
906
+ *
907
+ * One current consumer does read the difference: the direct-chat session strip
908
+ * (`buildAgentSessionPresentation`, which early-returns for group chats) labels
909
+ * turn state `streaming` "Streaming" / "Live preview" and `thinking`
910
+ * "Thinking". So a direct chat started with an explicit `--turn-verbosity
911
+ * quiet` reads "Thinking" for the whole turn — which is what a turn publishing
912
+ * no live preview actually is.
913
+ *
914
+ * `waiting_input` is deliberately NOT folded in: it is the one state that
915
+ * changes what the header says and how the clients treat the dots, and a turn
916
+ * blocked on a human is not thinking.
917
+ */
918
+ export function publishedClaudeTurnState(state, turnVerbosity) {
919
+ if (turnVerbosity !== 'quiet')
920
+ return state;
921
+ return state === 'streaming' || state === 'tool' ? 'thinking' : state;
922
+ }
923
+ /**
924
+ * The `/streaming` write for one snapshot, or `null` for none.
925
+ *
926
+ * Extracted from the session's `writeSnapshot` so the quiet gate is a unit a
927
+ * test can hold rather than one inline comparison a refactor can drop in
928
+ * silence. The gate cannot live in the controller's `mode`: the controller is
929
+ * per-SESSION here while verbosity is per-TURN.
930
+ *
931
+ * A quiet turn publishes no node at all rather than a status-only one. Writing
932
+ * then deleting would hand `onStreamingCleared` something to salvage into a
933
+ * durable bubble the reader was never meant to see; never writing leaves the
934
+ * delete with nothing to fire on.
935
+ */
936
+ export function planClaudeStreamingWrite(input) {
937
+ if (input.turnVerbosity === 'quiet')
938
+ return null;
939
+ return {
940
+ text: input.snapshot.text,
941
+ status: input.snapshot.status,
942
+ messageId: input.turnId,
943
+ turnId: input.turnId,
944
+ ...(input.snapshot.blocks
945
+ ? { blocks: input.snapshot.blocks.map((block) => ({ ...block, turnId: input.turnId })) }
946
+ : {}),
947
+ };
948
+ }
949
+ /**
950
+ * When streamed text starts arriving, do the typing dots retire?
951
+ *
952
+ * In verbose they do, and should: the live bubble becomes the indicator, and
953
+ * two indicators for one turn is noise. A quiet turn has no bubble, so the
954
+ * dots are the only thing the reader has for the whole generation phase — the
955
+ * longest stretch of the turn. Spelled the same way on the Codex host.
956
+ */
957
+ export function shouldStopTypingDotsOnStreamedText(turnVerbosity) {
958
+ return turnVerbosity !== 'quiet';
959
+ }
868
960
  export const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated — the full answer exceeded Canon's message size limit]_";
869
961
  /** How far back from the cut a paragraph break is still worth cutting at. */
870
962
  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.3",
3
+ "version": "0.30.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.0.0",
35
- "@canonmsg/agent-tools": "^0.3.4",
34
+ "@canonmsg/agent-sdk": "^8.2.0",
35
+ "@canonmsg/agent-tools": "^0.4.0",
36
36
  "@canonmsg/coding-agent-host": "^0.4.0",
37
- "@canonmsg/core": "^9.1.0",
38
- "@canonmsg/rich-cards": "^0.9.0",
37
+ "@canonmsg/core": "^10.0.0",
38
+ "@canonmsg/rich-cards": "^0.9.1",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0"
40
40
  },
41
41
  "engines": {
@@ -18,7 +18,7 @@ Register a new Canon agent so it can send and receive messages. Each agent is sa
18
18
  - **Agent name** — The display name for the agent in Canon
19
19
  - **Description** — What the agent does (shown to users in Canon)
20
20
  - **Owner phone number** — The Canon account owner's phone number in E.164 format (e.g., +15551234567)
21
- - **Canon environment** — The trust-domain ID supplied by Canon (`canon-prod-v1` for production, `canon-dev-v1` for dev). There is no CLI default; the command fails without it.
21
+ - **Canon environment** — The trust-domain ID supplied by Canon (`canon-prod-v1` for production, `canon-dev-v1` for dev). Left unset, the CLI registers against `canon-prod-v1` and says so; always confirm with the user rather than letting it default.
22
22
  - **Profile name** (optional) — A short identifier for this agent (e.g., "reviewer", "notifier"). Defaults to a sanitized version of the agent name.
23
23
 
24
24
  2. Run the registration CLI: