@canonmsg/codex-plugin 0.23.7 → 0.24.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.
package/README.md CHANGED
@@ -55,6 +55,10 @@ You do not need a git repo for host mode. Any readable working directory is vali
55
55
  - Interrupt by terminating the active Codex turn
56
56
  - Tool/running status surfaced while Codex is working
57
57
  - Reasoning-effort selection, resolved against what the active model accepts
58
+ - Deliberate silence: the model can end a turn without posting anything, by
59
+ calling the `codex_app.no_reply` tool (app-server transport only)
60
+ - Quiet group turns: in groups the host shows the thinking indicator and the
61
+ answer only; direct chats keep the live preview and margin activity
58
62
 
59
63
  ## Transports
60
64
 
@@ -70,7 +74,9 @@ deltas, and model/effort discovery from the runtime.
70
74
  state, tool activity, and completed assistant-message previews, but not
71
75
  token-by-token deltas, and it cannot block on approvals — Canon labels the
72
76
  session with that warning rather than implying a gate it does not have. Without
73
- discovery it also offers no model picker and a fixed effort list.
77
+ discovery it also offers no model picker and a fixed effort list. It registers
78
+ no dynamic tools either, so `no_reply` is unavailable there, the same way rich
79
+ cards are.
74
80
 
75
81
  Set `CANON_CODEX_TRANSPORT=exec` or `CANON_CODEX_TRANSPORT=app-server` to skip
76
82
  the probe when it misfires.
@@ -127,6 +133,36 @@ switch to `--full-auto`.
127
133
 
128
134
  Do not start Canon with `--sandbox danger-full-access` as an unlabeled default. Use `--dangerously-bypass-approvals-and-sandbox` only when you intentionally want Canon to advertise the owner-only Bypass policy.
129
135
 
136
+ ### Turn verbosity
137
+
138
+ ```bash
139
+ canon-codex --cwd /path/to/project --turn-verbosity quiet
140
+ ```
141
+
142
+ `--turn-verbosity <verbose|quiet|auto>` controls how much of a turn's middle
143
+ readers see. `CANON_TURN_VERBOSITY` is the environment fallback; the flag wins
144
+ when both are set.
145
+
146
+ | Value | Effect |
147
+ |---|---|
148
+ | `auto` (default, same as unset) | Verbose in direct chats, quiet in groups |
149
+ | `verbose` | Live streaming text plus the margin activity rows on the final, everywhere |
150
+ | `quiet` | The thinking indicator and the answer, nothing in between, everywhere |
151
+
152
+ Quiet drops the live `/streaming` narration — including the plan text this host
153
+ would otherwise publish as the agent's speech — and the final's `turnTrail`
154
+ activity rows. It does **not** drop the thinking indicator (which now stays up
155
+ for the turn's whole working phase rather than handing over to a bubble that
156
+ never appears — while the turn is parked on an approval the clients suppress an
157
+ agent's dots and the header line carries the state, and the dots come back when
158
+ the human answers), the turn state, the answer including every part of a long chunked one, failure
159
+ notices, workspace artifacts, or approval and question cards and their receipts.
160
+
161
+ This host parses flags strictly, so an unknown flag stops it at startup; an
162
+ unrecognized `--turn-verbosity` VALUE is reported once and then ignored in
163
+ favour of the default, because taking a local agent offline over a presentation
164
+ setting would be the worse failure.
165
+
130
166
  Local smoke test:
131
167
 
132
168
  ```bash
@@ -35,8 +35,48 @@ type DynamicToolCallResponse = {
35
35
  text: string;
36
36
  }>;
37
37
  };
38
+ /**
39
+ * Deliberate silence (`canon.verbs.v1` `no_reply`). The model sees it as
40
+ * `codex_app.no_reply` — the dynamic-tool namespace is fixed by the transport —
41
+ * and the Canon host answers it directly, because the flag it sets belongs to
42
+ * the host's turn, not to the app-server bridge.
43
+ */
44
+ export declare const CODEX_NO_REPLY_TOOL_NAME = "no_reply";
45
+ /**
46
+ * What the model actually sees for the tool above, for prompt text that names
47
+ * it (the group posture cue). Only meaningful on the app-server transport —
48
+ * the `exec --json` transport registers no dynamic tools.
49
+ */
50
+ export declare const CODEX_NO_REPLY_MODEL_TOOL_NAME = "codex_app.no_reply";
38
51
  export declare const CODEX_APP_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
39
52
  export declare function isCodexAppToolCall(params: Record<string, unknown>): boolean;
40
53
  export declare function deniedCodexAppToolResult(reason: string): DynamicToolCallResponse;
54
+ /** True when this dynamic-tool call is the deliberate-silence verb. */
55
+ export declare function isCodexNoReplyToolCall(params: CodexAppToolCallParams): boolean;
56
+ /** Private rationale, when the model supplied one. Logged, never rendered. */
57
+ export declare function readCodexNoReplyReason(params: CodexAppToolCallParams): string | undefined;
58
+ /**
59
+ * The model-facing closure sentence, read from the same definition the server's
60
+ * `no_reply` ack sends so the two can never drift.
61
+ */
62
+ export declare function codexNoReplyToolResult(): DynamicToolCallResponse;
63
+ /** How the host should answer one `codex_app` dynamic-tool call. */
64
+ export type CodexAppToolDisposition = 'no-reply' | 'denied-transport' | 'denied-non-owner' | 'bridge';
65
+ /**
66
+ * The whole `codex_app` admission decision, as data.
67
+ *
68
+ * `no-reply` is deliberately ranked ABOVE the owner gate: a group turn is
69
+ * usually a non-owner turn, and those are exactly the turns silence exists for,
70
+ * so gating it would mean the feature can never fire where it is needed.
71
+ * Staying quiet also grants nothing — it is the absence of an action. That
72
+ * ordering is the one line the whole Codex binding rests on, which is why it
73
+ * lives here, in a pure function a test can pin, instead of only in the host
74
+ * closure.
75
+ */
76
+ export declare function classifyCodexAppToolRequest(input: {
77
+ params: CodexAppToolCallParams;
78
+ transportSupportsAppTools: boolean;
79
+ canUseAppTools: boolean;
80
+ }): CodexAppToolDisposition;
41
81
  export declare function handleCodexAppToolCall(runtime: CodexAppToolRuntime, params: CodexAppToolCallParams): Promise<DynamicToolCallResponse>;
42
82
  export {};
@@ -1,3 +1,4 @@
1
+ import { NO_REPLY_ACK_NOTE } from '@canonmsg/core';
1
2
  const emptyObjectSchema = {
2
3
  type: 'object',
3
4
  properties: {},
@@ -81,6 +82,19 @@ const forkEnvironmentSchema = {
81
82
  function tool(name, description, inputSchema, deferLoading = true) {
82
83
  return { namespace: 'codex_app', name, description, inputSchema, deferLoading };
83
84
  }
85
+ /**
86
+ * Deliberate silence (`canon.verbs.v1` `no_reply`). The model sees it as
87
+ * `codex_app.no_reply` — the dynamic-tool namespace is fixed by the transport —
88
+ * and the Canon host answers it directly, because the flag it sets belongs to
89
+ * the host's turn, not to the app-server bridge.
90
+ */
91
+ export const CODEX_NO_REPLY_TOOL_NAME = 'no_reply';
92
+ /**
93
+ * What the model actually sees for the tool above, for prompt text that names
94
+ * it (the group posture cue). Only meaningful on the app-server transport —
95
+ * the `exec --json` transport registers no dynamic tools.
96
+ */
97
+ export const CODEX_NO_REPLY_MODEL_TOOL_NAME = `codex_app.${CODEX_NO_REPLY_TOOL_NAME}`;
84
98
  export const CODEX_APP_DYNAMIC_TOOLS = [
85
99
  tool('automation_update', 'Create, update, view, or delete Codex app automations. Canon exposes the name for compatibility, but does not manage Desktop automations.', {
86
100
  type: 'object',
@@ -191,6 +205,23 @@ export const CODEX_APP_DYNAMIC_TOOLS = [
191
205
  },
192
206
  required: ['threadId', 'title'],
193
207
  }),
208
+ // Canon's `no_reply` verb, projected into the only model-visible tool surface
209
+ // the Codex transport gives us. It is answered by the Canon host itself (see
210
+ // `handleCodexServerRequest`), never by `handleCodexAppToolCall` — the host
211
+ // owns the turn this call is about.
212
+ tool(CODEX_NO_REPLY_TOOL_NAME, 'End your turn without posting anything to the conversation. Use it in group '
213
+ + 'chats when you have nothing to add — no message is created, so no other '
214
+ + 'member or agent is triggered. Optional private reason (logged, never '
215
+ + 'shown). After calling this, produce no further text.', {
216
+ type: 'object',
217
+ additionalProperties: false,
218
+ properties: {
219
+ reason: {
220
+ type: 'string',
221
+ description: 'Never rendered; logged only.',
222
+ },
223
+ },
224
+ }, false),
194
225
  ];
195
226
  const CODEX_APP_TOOL_NAMES = new Set(CODEX_APP_DYNAMIC_TOOLS.map((entry) => String(entry.name)));
196
227
  const UNSUPPORTED_TOOLS = new Map([
@@ -215,11 +246,56 @@ export function isCodexAppToolCall(params) {
215
246
  export function deniedCodexAppToolResult(reason) {
216
247
  return toolResult(false, { error: reason });
217
248
  }
249
+ /** True when this dynamic-tool call is the deliberate-silence verb. */
250
+ export function isCodexNoReplyToolCall(params) {
251
+ return normalizeToolName(params.tool) === CODEX_NO_REPLY_TOOL_NAME;
252
+ }
253
+ /** Private rationale, when the model supplied one. Logged, never rendered. */
254
+ export function readCodexNoReplyReason(params) {
255
+ const args = parseToolArguments(params.arguments);
256
+ return readString(args, 'reason');
257
+ }
258
+ /**
259
+ * The model-facing closure sentence, read from the same definition the server's
260
+ * `no_reply` ack sends so the two can never drift.
261
+ */
262
+ export function codexNoReplyToolResult() {
263
+ return toolResult(true, { status: 'acknowledged', note: NO_REPLY_ACK_NOTE });
264
+ }
265
+ /**
266
+ * The whole `codex_app` admission decision, as data.
267
+ *
268
+ * `no-reply` is deliberately ranked ABOVE the owner gate: a group turn is
269
+ * usually a non-owner turn, and those are exactly the turns silence exists for,
270
+ * so gating it would mean the feature can never fire where it is needed.
271
+ * Staying quiet also grants nothing — it is the absence of an action. That
272
+ * ordering is the one line the whole Codex binding rests on, which is why it
273
+ * lives here, in a pure function a test can pin, instead of only in the host
274
+ * closure.
275
+ */
276
+ export function classifyCodexAppToolRequest(input) {
277
+ if (!input.transportSupportsAppTools)
278
+ return 'denied-transport';
279
+ if (isCodexNoReplyToolCall(input.params))
280
+ return 'no-reply';
281
+ if (!input.canUseAppTools)
282
+ return 'denied-non-owner';
283
+ return 'bridge';
284
+ }
218
285
  export async function handleCodexAppToolCall(runtime, params) {
219
286
  const toolName = normalizeToolName(params.tool);
220
287
  if (!toolName || !CODEX_APP_TOOL_NAMES.has(toolName)) {
221
288
  return toolResult(false, { error: `Unsupported codex_app tool: ${String(params.tool ?? 'unknown')}` });
222
289
  }
290
+ if (toolName === CODEX_NO_REPLY_TOOL_NAME) {
291
+ // Unreachable through the host, which intercepts `no_reply` before this
292
+ // bridge (only the host can mark its own turn silent). Answering with a
293
+ // bare ack here would tell the model it went quiet when nothing did.
294
+ return toolResult(false, {
295
+ tool: toolName,
296
+ error: 'no_reply is answered by the Canon host, not the app-tool bridge.',
297
+ });
298
+ }
223
299
  const unsupportedReason = UNSUPPORTED_TOOLS.get(toolName);
224
300
  if (unsupportedReason) {
225
301
  return toolResult(false, { tool: toolName, error: unsupportedReason });
package/dist/host.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type WorkspaceOption, type CanonWorkspaceRootMetadata } from '@canonmsg/core';
2
+ import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type WorkspaceOption, type CanonWorkspaceRootMetadata, type RuntimeStreamingPayload, type TurnLifecycleState, type TurnOutputBlock, type TurnVerbosity, type TurnVerbosityConfig } from '@canonmsg/core';
3
3
  import { type CodexSkillMetadata } from './app-server-adapter.js';
4
4
  import { type CodexControlOption } from './model-catalog.js';
5
5
  interface HostSessionState {
@@ -67,5 +67,74 @@ export declare function getCodexRequestingUserId(message: {
67
67
  senderId: string;
68
68
  senderType?: 'human' | 'ai_agent';
69
69
  }): string | null;
70
+ /**
71
+ * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
72
+ * per-conversation-type default".
73
+ *
74
+ * An unparseable value is reported and ignored rather than fatal. This host's
75
+ * `parseArgs` is `strict: true`, so an unknown FLAG already stops the process —
76
+ * that is the check worth having. A wrong VALUE is a presentation choice, and
77
+ * taking a local agent offline over one would be a worse failure than showing
78
+ * the default. An empty declaration (`ENV=` in a Dockerfile, `--turn-verbosity
79
+ * ''`) is absence, not a mistake, so it says nothing.
80
+ */
81
+ export declare function resolveConfiguredCodexTurnVerbosity(input: {
82
+ flag?: unknown;
83
+ env?: string | undefined;
84
+ onWarning?: (message: string) => void;
85
+ }): TurnVerbosityConfig | null;
86
+ /**
87
+ * The turn state a quiet turn publishes to `/turn-state`.
88
+ *
89
+ * Current clients render "is thinking" for every open non-waiting state, so
90
+ * the chat header is unchanged. It exists for app binaries older than #602,
91
+ * whose typing-dot filter suppressed an agent on turn state `streaming`/`tool`
92
+ * because a live bubble was expected to carry the state instead — and a quiet
93
+ * turn has no bubble. The one current consumer that reads the difference is the
94
+ * direct-chat session strip, which labels `streaming` "Streaming" / "Live
95
+ * preview"; a direct chat started with an explicit quiet therefore reads
96
+ * "Thinking" for the whole turn, which is what it is. `waiting_input` is left
97
+ * alone: it changes what the header says and how the clients treat the dots,
98
+ * and a turn blocked on a human is not thinking.
99
+ */
100
+ export declare function publishedCodexTurnState(state: TurnLifecycleState, turnVerbosity: TurnVerbosity): TurnLifecycleState;
101
+ /**
102
+ * The `/streaming` write for one live-node update, or `null` for none.
103
+ *
104
+ * Extracted from `writeCodexStreaming` so the quiet gate covering all eight
105
+ * call sites — the turn-open seed, assistant text, the plan update (which
106
+ * passes its text as the node's SPEECH, the loudest step emission this host
107
+ * has), waiting, command start/completion, and the two card transitions — is a
108
+ * unit a test can hold, rather than one inline comparison a refactor can drop
109
+ * in silence.
110
+ *
111
+ * `liveText` is returned in BOTH modes and is deliberately outside the gate:
112
+ * every downstream reader, including the final trail and the turn-exit error,
113
+ * still has to see what the turn produced. A quiet turn publishes no node at
114
+ * all rather than a status-only one, so `onStreamingCleared` has nothing to
115
+ * salvage into a durable bubble if the turn dies mid-flight.
116
+ */
117
+ export declare function planCodexStreamingWrite(input: {
118
+ turnVerbosity: TurnVerbosity;
119
+ /** The new live text, or `null` to keep what the turn already had. */
120
+ text: string | null;
121
+ liveText: string;
122
+ status: RuntimeStreamingPayload['status'];
123
+ turnId: string | null;
124
+ blocks: TurnOutputBlock[];
125
+ }): {
126
+ liveText: string;
127
+ write: RuntimeStreamingPayload | null;
128
+ };
129
+ /**
130
+ * When streamed text starts arriving, do the typing dots retire?
131
+ *
132
+ * In verbose they do, and should: the live bubble becomes the indicator, and
133
+ * two indicators for one turn is noise. A quiet turn has no bubble, so the
134
+ * dots are the only thing the reader has for the whole generation phase — the
135
+ * longest stretch of the turn. Shared with the Claude host, which spells the
136
+ * same decision at its `text_delta` handler.
137
+ */
138
+ export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
70
139
  export declare function main(): Promise<void>;
71
140
  export {};
package/dist/host.js CHANGED
@@ -6,10 +6,10 @@ import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
8
  import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, collectTurnArtifacts, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
9
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
9
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { CodexConversationAdapter, } from './adapter.js';
11
11
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
12
- import { CODEX_APP_DYNAMIC_TOOLS, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, } from './codex-app-tools.js';
12
+ import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME, classifyCodexAppToolRequest, codexNoReplyToolResult, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, readCodexNoReplyReason, } from './codex-app-tools.js';
13
13
  import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
14
14
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
15
15
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
@@ -18,7 +18,7 @@ import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodex
18
18
  import { startCodexStreamInBackground } from './host-lifecycle.js';
19
19
  import { createCodexControlPoller } from './control-channel.js';
20
20
  import { runCli } from '@canonmsg/core';
21
- import { applyTextSegmentBlock, beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
21
+ import { applyTextSegmentBlock, beginCommandBlock, buildCodexFinalTurnTrail, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
22
22
  import { FALLBACK_CODEX_EFFORT_OPTIONS, buildCodexEffortOptions, buildCodexModelOptions, readCodexConfiguredEffort, resolveCodexDefaultModel, resolveCodexEffortForModel, } from './model-catalog.js';
23
23
  const HELP = `canon-codex — run a local Codex agent host for Canon
24
24
 
@@ -38,6 +38,10 @@ COMMON FLAGS
38
38
  Detail visibility preset
39
39
  --show-runtime-detail <field> Override a hidden detail field
40
40
  --hide-runtime-detail <field> Hide a runtime detail field
41
+ --turn-verbosity <verbose|quiet|auto>
42
+ How much of a turn's middle readers see.
43
+ Default (auto): verbose in direct chats,
44
+ quiet in groups. Env: CANON_TURN_VERBOSITY
41
45
  --help, -h Show this help
42
46
  --version, -V Show package version
43
47
 
@@ -88,6 +92,12 @@ const CODEX_RUNTIME_CAPABILITIES = {
88
92
  supportsNonFinalPermanentMessages: false,
89
93
  };
90
94
  let workingDir = process.cwd();
95
+ /**
96
+ * Agent-developer setting, resolved once at startup. Deliberately NOT read from
97
+ * `/session-config`: that path is the USER's per-conversation control plane,
98
+ * and owner ruling 5 puts turn verbosity outside user control.
99
+ */
100
+ let configuredTurnVerbosity = null;
91
101
  let workspaceOptions = [];
92
102
  let workspaceRoots = [];
93
103
  let workspaceRootMetadata = [];
@@ -321,7 +331,7 @@ function buildCanonPrompt(input) {
321
331
  provenance: input.provenance,
322
332
  replyContext: input.replyContext,
323
333
  message: input.message,
324
- })));
334
+ })), input.noReplyToolName ? { noReplyToolName: input.noReplyToolName } : {});
325
335
  }
326
336
  function renderInboundContent(message, materialized) {
327
337
  return renderCanonHostInboundContent(message, materialized);
@@ -440,6 +450,94 @@ function readString(record, key) {
440
450
  const value = record[key];
441
451
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
442
452
  }
453
+ /**
454
+ * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
455
+ * per-conversation-type default".
456
+ *
457
+ * An unparseable value is reported and ignored rather than fatal. This host's
458
+ * `parseArgs` is `strict: true`, so an unknown FLAG already stops the process —
459
+ * that is the check worth having. A wrong VALUE is a presentation choice, and
460
+ * taking a local agent offline over one would be a worse failure than showing
461
+ * the default. An empty declaration (`ENV=` in a Dockerfile, `--turn-verbosity
462
+ * ''`) is absence, not a mistake, so it says nothing.
463
+ */
464
+ export function resolveConfiguredCodexTurnVerbosity(input) {
465
+ const sources = [
466
+ ['--turn-verbosity', typeof input.flag === 'string' ? input.flag : undefined],
467
+ ['CANON_TURN_VERBOSITY', input.env],
468
+ ];
469
+ for (const [name, raw] of sources) {
470
+ const parsed = parseTurnVerbosityConfig(raw);
471
+ if (parsed)
472
+ return parsed;
473
+ if (raw && raw.trim()) {
474
+ input.onWarning?.(`Ignoring ${name}=${JSON.stringify(raw)} — expected verbose, quiet or auto.`);
475
+ }
476
+ }
477
+ return null;
478
+ }
479
+ /**
480
+ * The turn state a quiet turn publishes to `/turn-state`.
481
+ *
482
+ * Current clients render "is thinking" for every open non-waiting state, so
483
+ * the chat header is unchanged. It exists for app binaries older than #602,
484
+ * whose typing-dot filter suppressed an agent on turn state `streaming`/`tool`
485
+ * because a live bubble was expected to carry the state instead — and a quiet
486
+ * turn has no bubble. The one current consumer that reads the difference is the
487
+ * direct-chat session strip, which labels `streaming` "Streaming" / "Live
488
+ * preview"; a direct chat started with an explicit quiet therefore reads
489
+ * "Thinking" for the whole turn, which is what it is. `waiting_input` is left
490
+ * alone: it changes what the header says and how the clients treat the dots,
491
+ * and a turn blocked on a human is not thinking.
492
+ */
493
+ export function publishedCodexTurnState(state, turnVerbosity) {
494
+ if (turnVerbosity !== 'quiet')
495
+ return state;
496
+ return state === 'streaming' || state === 'tool' ? 'thinking' : state;
497
+ }
498
+ /**
499
+ * The `/streaming` write for one live-node update, or `null` for none.
500
+ *
501
+ * Extracted from `writeCodexStreaming` so the quiet gate covering all eight
502
+ * call sites — the turn-open seed, assistant text, the plan update (which
503
+ * passes its text as the node's SPEECH, the loudest step emission this host
504
+ * has), waiting, command start/completion, and the two card transitions — is a
505
+ * unit a test can hold, rather than one inline comparison a refactor can drop
506
+ * in silence.
507
+ *
508
+ * `liveText` is returned in BOTH modes and is deliberately outside the gate:
509
+ * every downstream reader, including the final trail and the turn-exit error,
510
+ * still has to see what the turn produced. A quiet turn publishes no node at
511
+ * all rather than a status-only one, so `onStreamingCleared` has nothing to
512
+ * salvage into a durable bubble if the turn dies mid-flight.
513
+ */
514
+ export function planCodexStreamingWrite(input) {
515
+ const liveText = input.text !== null ? input.text : input.liveText;
516
+ if (input.turnVerbosity === 'quiet')
517
+ return { liveText, write: null };
518
+ return {
519
+ liveText,
520
+ write: {
521
+ text: liveText,
522
+ status: input.status,
523
+ messageId: input.turnId ?? undefined,
524
+ turnId: input.turnId,
525
+ blocks: input.blocks,
526
+ },
527
+ };
528
+ }
529
+ /**
530
+ * When streamed text starts arriving, do the typing dots retire?
531
+ *
532
+ * In verbose they do, and should: the live bubble becomes the indicator, and
533
+ * two indicators for one turn is noise. A quiet turn has no bubble, so the
534
+ * dots are the only thing the reader has for the whole generation phase — the
535
+ * longest stretch of the turn. Shared with the Claude host, which spells the
536
+ * same decision at its `text_delta` handler.
537
+ */
538
+ export function shouldStopTypingDotsOnStreamedText(turnVerbosity) {
539
+ return turnVerbosity !== 'quiet';
540
+ }
443
541
  export async function main() {
444
542
  setDefaultResultOrder('ipv4first');
445
543
  const { values: args } = parseArgs({
@@ -457,6 +555,7 @@ export async function main() {
457
555
  'runtime-visibility': { type: 'string' },
458
556
  'show-runtime-detail': { type: 'string', multiple: true },
459
557
  'hide-runtime-detail': { type: 'string', multiple: true },
558
+ 'turn-verbosity': { type: 'string' },
460
559
  'full-auto': { type: 'boolean' },
461
560
  'dangerously-bypass-approvals-and-sandbox': { type: 'boolean' },
462
561
  },
@@ -467,6 +566,11 @@ export async function main() {
467
566
  process.exitCode = 1;
468
567
  return;
469
568
  }
569
+ configuredTurnVerbosity = resolveConfiguredCodexTurnVerbosity({
570
+ flag: args['turn-verbosity'],
571
+ env: process.env.CANON_TURN_VERBOSITY,
572
+ onWarning: (message) => console.error(`[canon-codex] ${message}`),
573
+ });
470
574
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
471
575
  const workspaceDiscovery = buildConfiguredWorkspaceOptionsWithRoots({
472
576
  primaryCwd: workingDir,
@@ -748,7 +852,7 @@ export async function main() {
748
852
  || session.turnState === 'waiting_input';
749
853
  runtimeState.writeTurnState(session.conversationId, {
750
854
  turnId: session.currentTurnId,
751
- state: session.turnState,
855
+ state: publishedCodexTurnState(session.turnState, session.turnVerbosity),
752
856
  queueDepth: session.queue.length,
753
857
  currentSpeakerId: agentId,
754
858
  lastAcceptedIntent: session.lastAcceptedIntent,
@@ -824,15 +928,39 @@ export async function main() {
824
928
  runtimeState.clearStreaming(conversationId).catch(() => { });
825
929
  }
826
930
  function writeCodexStreaming(session, text, status) {
827
- if (text !== null) {
828
- session.turnLiveText = text;
829
- }
830
- runtimeState.writeStreaming(session.conversationId, {
831
- text: session.turnLiveText,
931
+ const plan = planCodexStreamingWrite({
932
+ turnVerbosity: session.turnVerbosity,
933
+ text,
934
+ liveText: session.turnLiveText,
832
935
  status,
833
- messageId: session.currentTurnId ?? undefined,
834
936
  turnId: session.currentTurnId,
835
937
  blocks: session.turnBlocks,
938
+ });
939
+ session.turnLiveText = plan.liveText;
940
+ if (!plan.write)
941
+ return;
942
+ runtimeState.writeStreaming(session.conversationId, plan.write).catch(() => { });
943
+ }
944
+ /**
945
+ * Blanks the live node for a turn that chose silence. Awaited, unlike
946
+ * `writeCodexStreaming`: the delete that follows must not race ahead of this
947
+ * write, or the trigger salvages the un-blanked value into a durable message.
948
+ * The empty `blocks` array is load-bearing — `fallbackTextFromBlocks`
949
+ * rebuilds salvage text out of tool-trail titles when only `text` is cleared.
950
+ *
951
+ * Left unconditional under quiet, where there is nothing to scrub: the write
952
+ * costs one no-op round trip on a rare path, and keeping the deliberate-
953
+ * silence sequence identical in both modes is worth more than saving it.
954
+ */
955
+ async function blankCodexStreaming(session) {
956
+ session.turnLiveText = '';
957
+ session.turnBlocks = [];
958
+ await runtimeState.writeStreaming(session.conversationId, {
959
+ text: '',
960
+ status: 'thinking',
961
+ messageId: session.currentTurnId ?? undefined,
962
+ turnId: session.currentTurnId,
963
+ blocks: [],
836
964
  }).catch(() => { });
837
965
  }
838
966
  function upsertCodexTextSegment(session, event) {
@@ -882,10 +1010,11 @@ export async function main() {
882
1010
  });
883
1011
  }
884
1012
  function buildFinalTurnTrail(session) {
885
- return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
886
- ...block,
887
- turnId: session.currentTurnId ?? block.turnId,
888
- })));
1013
+ return buildCodexFinalTurnTrail({
1014
+ blocks: session.turnBlocks,
1015
+ turnId: session.currentTurnId,
1016
+ turnVerbosity: session.turnVerbosity,
1017
+ });
889
1018
  }
890
1019
  function buildCodexMessageId(session, kind) {
891
1020
  return `codex-${kind}-${session.currentTurnId ?? randomUUID()}`;
@@ -908,6 +1037,21 @@ export async function main() {
908
1037
  function startVisibleWorkSignal(session) {
909
1038
  refreshVisibleWorkSignal(session);
910
1039
  }
1040
+ /**
1041
+ * Start the dots whatever the turn state says.
1042
+ *
1043
+ * `refreshVisibleWorkSignal` filters on `thinking`/`tool`, which is right for
1044
+ * a verbose turn — once text starts flowing the live bubble is the indicator,
1045
+ * so the dots are meant to retire. A quiet turn has no bubble, so the dots
1046
+ * have to survive the flip to `streaming`, and the filtered helper would
1047
+ * silently do nothing. Cheap to call repeatedly: the publisher skips the
1048
+ * write when it is already active with the same status.
1049
+ */
1050
+ function ensureVisibleWorkSignal(session) {
1051
+ if (!session.running || session.closed)
1052
+ return;
1053
+ typingSignals.start(session.conversationId, 'thinking').catch(() => { });
1054
+ }
911
1055
  function stopVisibleWorkSignal(session) {
912
1056
  if (session.typingKeepaliveTimer) {
913
1057
  clearInterval(session.typingKeepaliveTimer);
@@ -915,6 +1059,29 @@ export async function main() {
915
1059
  }
916
1060
  typingSignals.clear(session.conversationId).catch(() => { });
917
1061
  }
1062
+ /**
1063
+ * A turn parked on an approval, an input request or a card is working again
1064
+ * the moment the answer arrives.
1065
+ *
1066
+ * Codex announces the park — a `thread/status/changed` carrying
1067
+ * `waitingOnApproval`/`waitingOnUserInput`, which the adapter forwards as
1068
+ * `waiting` — but announces no resume, so the host has to draw that edge
1069
+ * itself. Without it `/turn-state` keeps saying "waiting for your reply"
1070
+ * while the agent generates, and the dots stopped at the park never come
1071
+ * back: the clients suppress an agent's dots on `waiting_input`, and the
1072
+ * events that follow a resume (`message`, `plan.updated`) set the state to
1073
+ * `streaming`, which `refreshVisibleWorkSignal` skips. The Claude host draws
1074
+ * the same edge off its `session_state_changed: running` echo.
1075
+ */
1076
+ function resumeTurnFromWaiting(session) {
1077
+ if (session.turnState !== 'waiting_input')
1078
+ return;
1079
+ session.turnState = 'thinking';
1080
+ markTurnProgress(session);
1081
+ writeTurn(session);
1082
+ startVisibleWorkSignal(session);
1083
+ writeCodexStreaming(session, null, 'thinking');
1084
+ }
918
1085
  function closeSession(conversationId) {
919
1086
  const session = sessions.get(conversationId);
920
1087
  if (!session)
@@ -952,6 +1119,7 @@ export async function main() {
952
1119
  session.currentTurnOpenedAt = null;
953
1120
  session.currentTurnUpdatedAt = null;
954
1121
  session.currentTurnCanUseCodexAppTools = false;
1122
+ session.currentTurnSilenced = false;
955
1123
  session.lastAcceptedIntent = null;
956
1124
  session.resetRequested = false;
957
1125
  }
@@ -1062,6 +1230,10 @@ export async function main() {
1062
1230
  currentTurnOpenedAt: null,
1063
1231
  currentTurnUpdatedAt: null,
1064
1232
  currentTurnCanUseCodexAppTools: false,
1233
+ // Corrected by the first turn that runs; a session with no turn
1234
+ // publishes nothing anyway, and quiet is never an accident.
1235
+ turnVerbosity: 'verbose',
1236
+ currentTurnSilenced: false,
1065
1237
  activeSelfContextId: null,
1066
1238
  lastAcceptedIntent: null,
1067
1239
  pendingDroppedRecoveryCursor: null,
@@ -1093,7 +1265,7 @@ export async function main() {
1093
1265
  pendingSessionCreations.delete(conversationId);
1094
1266
  }
1095
1267
  }
1096
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false, requestingUserId = null) {
1268
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, turn = {}) {
1097
1269
  const nextPrompt = {
1098
1270
  prompt,
1099
1271
  intent,
@@ -1102,9 +1274,10 @@ export async function main() {
1102
1274
  imagePaths,
1103
1275
  mediaAddDirs,
1104
1276
  planMode,
1105
- artifactRoutingMode,
1106
- canUseCodexAppTools,
1107
- requestingUserId,
1277
+ artifactRoutingMode: turn.artifactRoutingMode ?? 'disabled',
1278
+ canUseCodexAppTools: turn.canUseCodexAppTools ?? false,
1279
+ ...(turn.turnVerbosity ? { turnVerbosity: turn.turnVerbosity } : {}),
1280
+ requestingUserId: turn.requestingUserId ?? null,
1108
1281
  recoverySequence: ++inboundRecoverySequence,
1109
1282
  };
1110
1283
  if (toFront) {
@@ -1128,13 +1301,33 @@ export async function main() {
1128
1301
  : result.status === 'reject'
1129
1302
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1130
1303
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1131
- enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', 'disabled', false, responseUserId);
1304
+ // No `turnVerbosity`: this prompt continues the same conversation, so it
1305
+ // keeps whatever the session last resolved rather than reverting to the
1306
+ // default.
1307
+ enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', { requestingUserId: responseUserId });
1132
1308
  }
1133
1309
  function resolveArtifactRoutingMode(participantContext) {
1134
1310
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1135
1311
  ? 'workspace-generated'
1136
1312
  : 'disabled';
1137
1313
  }
1314
+ /**
1315
+ * Everything about a turn that is decided from the message that started it,
1316
+ * in one place. Resolved at the enqueue site and carried on the queue entry,
1317
+ * so a prompt that waits behind another still runs under the answer it
1318
+ * arrived with.
1319
+ */
1320
+ function resolveCodexTurnModes(participantContext, message) {
1321
+ return {
1322
+ artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
1323
+ canUseCodexAppTools: participantContext.isOwner,
1324
+ turnVerbosity: resolveTurnVerbosity({
1325
+ configured: configuredTurnVerbosity,
1326
+ conversationType: participantContext.conversationType,
1327
+ }),
1328
+ requestingUserId: getCodexRequestingUserId(message),
1329
+ };
1330
+ }
1138
1331
  function runtimeCardRequestPayload(method, params) {
1139
1332
  if (method !== 'item/runtimeCard/request'
1140
1333
  && method !== 'runtimeCard/request') {
@@ -1149,12 +1342,28 @@ export async function main() {
1149
1342
  const params = request.params;
1150
1343
  const expiresAt = Date.now() + 30 * 60_000;
1151
1344
  if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1152
- if (!(session.adapter instanceof CodexAppServerAdapter)) {
1153
- return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1345
+ // The admission order — no_reply above the owner gate — is pinned by
1346
+ // `classifyCodexAppToolRequest`'s tests, not by the shape of this block.
1347
+ const disposition = classifyCodexAppToolRequest({
1348
+ params,
1349
+ transportSupportsAppTools: session.adapter instanceof CodexAppServerAdapter,
1350
+ canUseAppTools: session.currentTurnCanUseCodexAppTools,
1351
+ });
1352
+ if (disposition === 'no-reply') {
1353
+ session.currentTurnSilenced = true;
1354
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn chose no_reply`
1355
+ + ` (reason: ${readCodexNoReplyReason(params) ? 'given' : 'none'})`);
1356
+ return codexNoReplyToolResult();
1154
1357
  }
1155
- if (!session.currentTurnCanUseCodexAppTools) {
1358
+ if (disposition === 'denied-non-owner') {
1156
1359
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1157
1360
  }
1361
+ // `disposition` already ruled on the transport (it is checked first, so a
1362
+ // wrong transport never reaches the branches above). This repeats the
1363
+ // test only to narrow the adapter type for the bridge call.
1364
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1365
+ return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1366
+ }
1158
1367
  return await handleCodexAppToolCall({
1159
1368
  adapter: session.adapter,
1160
1369
  currentThreadId: session.adapter.getThreadId(),
@@ -1234,13 +1443,7 @@ export async function main() {
1234
1443
  },
1235
1444
  });
1236
1445
  completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1237
- if (session.turnState === 'waiting_input') {
1238
- session.turnState = 'thinking';
1239
- markTurnProgress(session);
1240
- writeTurn(session);
1241
- startVisibleWorkSignal(session);
1242
- writeCodexStreaming(session, null, 'thinking');
1243
- }
1446
+ resumeTurnFromWaiting(session);
1244
1447
  return response;
1245
1448
  }
1246
1449
  catch (error) {
@@ -1298,6 +1501,7 @@ export async function main() {
1298
1501
  },
1299
1502
  turnId: session.currentTurnId ?? undefined,
1300
1503
  }, { requestId: inputId, expiresAt });
1504
+ resumeTurnFromWaiting(session);
1301
1505
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1302
1506
  }
1303
1507
  const mappedApproval = mapCodexAppServerApprovalRequest({
@@ -1316,6 +1520,7 @@ export async function main() {
1316
1520
  : {}),
1317
1521
  allowSessionRule: responseRouting.allowSessionRule,
1318
1522
  }, { requestId: approvalId, expiresAt });
1523
+ resumeTurnFromWaiting(session);
1319
1524
  if (request.method === 'item/permissions/requestApproval') {
1320
1525
  return response.decision === 'allow'
1321
1526
  ? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
@@ -1354,7 +1559,10 @@ export async function main() {
1354
1559
  : decision === 'reject'
1355
1560
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1356
1561
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1357
- enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner, getCodexRequestingUserId(input.message));
1562
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', {
1563
+ canUseCodexAppTools: input.isOwner,
1564
+ requestingUserId: getCodexRequestingUserId(input.message),
1565
+ });
1358
1566
  return;
1359
1567
  }
1360
1568
  let materialized = [];
@@ -1450,18 +1658,17 @@ export async function main() {
1450
1658
  provenance: hydrated.provenance,
1451
1659
  replyContext,
1452
1660
  message: input.message,
1661
+ ...(useAppServer ? { noReplyToolName: CODEX_NO_REPLY_MODEL_TOOL_NAME } : {}),
1453
1662
  });
1454
1663
  if (session.running && deliveryIntent === 'interrupt') {
1455
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1456
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner, getCodexRequestingUserId(input.message));
1664
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1457
1665
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1458
1666
  await session.adapter.interrupt().catch(() => { });
1459
1667
  clearStreaming(input.conversationId);
1460
1668
  typingSignals.clear(input.conversationId).catch(() => { });
1461
1669
  return;
1462
1670
  }
1463
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1464
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner, getCodexRequestingUserId(input.message));
1671
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1465
1672
  }
1466
1673
  function sendTurnArtifactFile(session, file) {
1467
1674
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
@@ -1520,6 +1727,10 @@ export async function main() {
1520
1727
  session.currentTurnOpenedAt = Date.now();
1521
1728
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1522
1729
  session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
1730
+ // A continuation prompt (a plan-review result) carries none, and keeps the
1731
+ // conversation's last answer rather than silently reverting to verbose.
1732
+ session.turnVerbosity = nextTurn.turnVerbosity ?? session.turnVerbosity;
1733
+ session.currentTurnSilenced = false;
1523
1734
  session.lastAcceptedIntent = nextTurn.intent;
1524
1735
  session.turnState = 'thinking';
1525
1736
  session.lastActivity = Date.now();
@@ -1527,9 +1738,24 @@ export async function main() {
1527
1738
  writeState(session);
1528
1739
  writeTurn(session);
1529
1740
  startVisibleWorkSignal(session);
1530
- // Status-only seed: 'thinking' renders as a working filament row on the
1531
- // clients; text here would be bubbled as speech (v4 register rule).
1532
- writeCodexStreaming(session, '', 'thinking');
1741
+ if (session.turnVerbosity === 'quiet') {
1742
+ // A quiet turn publishes nothing, so it cannot rely on the seed below to
1743
+ // overwrite a node an earlier verbose turn left behind — and a surviving
1744
+ // node carrying the PREVIOUS turn's id makes the clients drop this turn's
1745
+ // live row entirely. Delete instead. What `onStreamingCleared` does with
1746
+ // that delete is the same in both modes and is the ordering we want: a
1747
+ // node that is already gone has nothing to fire on, one left by a turn
1748
+ // that delivered its final is skipped as `turn_complete_exists`, and one
1749
+ // left by a turn that CRASHED mid-narration is salvaged into a durable
1750
+ // message — here, at turn open, before this turn's answer, rather than
1751
+ // arriving after it.
1752
+ clearStreaming(session.conversationId);
1753
+ }
1754
+ else {
1755
+ // Status-only seed: 'thinking' renders as a working filament row on the
1756
+ // clients; text here would be bubbled as speech (v4 register rule).
1757
+ writeCodexStreaming(session, '', 'thinking');
1758
+ }
1533
1759
  let artifactBaseline = null;
1534
1760
  let artifactsRouted = false;
1535
1761
  const routeArtifactsOnce = async () => {
@@ -1581,7 +1807,16 @@ export async function main() {
1581
1807
  session.turnState = 'streaming';
1582
1808
  markTurnProgress(session);
1583
1809
  writeTurn(session);
1584
- stopVisibleWorkSignal(session);
1810
+ // The dots stop here only because the live bubble takes over as the
1811
+ // indicator. A quiet turn has no bubble, so they have to keep
1812
+ // running — and `ensure`, not merely "don't stop": a turn resuming
1813
+ // from an approval had them cleared at the park.
1814
+ if (shouldStopTypingDotsOnStreamedText(session.turnVerbosity)) {
1815
+ stopVisibleWorkSignal(session);
1816
+ }
1817
+ else {
1818
+ ensureVisibleWorkSignal(session);
1819
+ }
1585
1820
  upsertCodexTextSegment(session, event);
1586
1821
  writeCodexStreaming(session, null, 'streaming');
1587
1822
  return;
@@ -1590,7 +1825,12 @@ export async function main() {
1590
1825
  session.turnState = 'streaming';
1591
1826
  markTurnProgress(session);
1592
1827
  writeTurn(session);
1593
- stopVisibleWorkSignal(session);
1828
+ if (shouldStopTypingDotsOnStreamedText(session.turnVerbosity)) {
1829
+ stopVisibleWorkSignal(session);
1830
+ }
1831
+ else {
1832
+ ensureVisibleWorkSignal(session);
1833
+ }
1594
1834
  upsertTurnBlock(session, {
1595
1835
  // Spelled through the shared helper, and with the same fallback
1596
1836
  // the other host uses: a plan arriving before a turn id would
@@ -1681,6 +1921,11 @@ export async function main() {
1681
1921
  && isRecoverableCodexThreadError(result.errorText)) {
1682
1922
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
1683
1923
  clearStoredThread();
1924
+ // The retry is a whole fresh model turn, so it starts from a fresh
1925
+ // flag — exactly like turn start does. Without this, a `no_reply` from
1926
+ // the attempt that broke would silence a reply the retry did intend
1927
+ // to send, with no diagnostic anywhere.
1928
+ session.currentTurnSilenced = false;
1684
1929
  result = await runTurnOnce();
1685
1930
  }
1686
1931
  if (session.adapter instanceof CodexAppServerAdapter) {
@@ -1697,7 +1942,17 @@ export async function main() {
1697
1942
  if (result.threadId && !session.resetRequested) {
1698
1943
  saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1699
1944
  }
1700
- if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1945
+ if (!result.interrupted
1946
+ && result.finalMessage
1947
+ && nextTurn.planMode
1948
+ // A plan card carries the model's final text into the conversation as a
1949
+ // visible, actionable artifact — it IS posting. Silence is strict here
1950
+ // too: a silenced plan turn falls through to the silent teardown below
1951
+ // and raises no card.
1952
+ && resolveSilentTurnDelivery({
1953
+ silenced: session.currentTurnSilenced,
1954
+ finalText: result.finalMessage,
1955
+ }) === 'deliver') {
1701
1956
  await routeArtifactsOnce();
1702
1957
  const responseRouting = buildCodexTurnResponseRouting({
1703
1958
  requestingUserId: nextTurn.requestingUserId,
@@ -1743,7 +1998,15 @@ export async function main() {
1743
1998
  await handoffFinalMessage(session.conversationId);
1744
1999
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1745
2000
  }
1746
- else if (!result.interrupted && result.finalMessage) {
2001
+ else if (!result.interrupted
2002
+ && result.finalMessage
2003
+ // A turn that called `no_reply` posts nothing. The failure branches
2004
+ // below are deliberately outside this gate: silence suppresses the
2005
+ // MODEL's reply, never Canon's own "this turn broke" diagnostic.
2006
+ && resolveSilentTurnDelivery({
2007
+ silenced: session.currentTurnSilenced,
2008
+ finalText: result.finalMessage,
2009
+ }) === 'deliver') {
1747
2010
  if (isRecoverableCodexThreadError(result.errorText)) {
1748
2011
  clearStoredThread();
1749
2012
  }
@@ -1791,6 +2054,22 @@ export async function main() {
1791
2054
  }
1792
2055
  else if (!result.interrupted) {
1793
2056
  await routeArtifactsOnce();
2057
+ if (session.currentTurnSilenced) {
2058
+ // Same thread hygiene the delivering branch does: a silent turn that
2059
+ // also hit a recoverable thread error must not leave the dead thread
2060
+ // id behind for the next turn to resume.
2061
+ if (isRecoverableCodexThreadError(result.errorText)) {
2062
+ clearStoredThread();
2063
+ }
2064
+ // Deliberate silence: blank the node (text '' AND an explicit empty
2065
+ // blocks array) before deleting it, or onStreamingCleared salvages
2066
+ // this turn's narration into a durable bubble. The live row and the
2067
+ // typing dots go together — leaving dots behind the removed row for
2068
+ // the handoff window reads as "started to answer, then gave up".
2069
+ await blankCodexStreaming(session);
2070
+ clearStreaming(session.conversationId);
2071
+ stopVisibleWorkSignal(session);
2072
+ }
1794
2073
  await handoffFinalMessage(session.conversationId);
1795
2074
  }
1796
2075
  else if (result.interrupted) {
@@ -1846,6 +2125,7 @@ export async function main() {
1846
2125
  session.currentTurnOpenedAt = null;
1847
2126
  session.currentTurnUpdatedAt = null;
1848
2127
  session.currentTurnCanUseCodexAppTools = false;
2128
+ session.currentTurnSilenced = false;
1849
2129
  session.lastAcceptedIntent = null;
1850
2130
  session.resetRequested = false;
1851
2131
  session.lastActivity = Date.now();
@@ -1,4 +1,4 @@
1
- import type { TurnOutputBlock } from '@canonmsg/core';
1
+ import type { TurnOutputBlock, TurnVerbosity } from '@canonmsg/core';
2
2
  interface RunningCommandBlock {
3
3
  command: string;
4
4
  blockId: string;
@@ -29,4 +29,18 @@ export declare function claimCommandBlock(tracker: CommandBlockTracker, input: {
29
29
  command: string;
30
30
  itemId?: string;
31
31
  }): string;
32
+ /**
33
+ * The margin trail to hang on this turn's final.
34
+ *
35
+ * The quiet gate lives here, separate from the live-node guard in the host,
36
+ * because the two are genuinely separate decisions: blocks accumulate whatever
37
+ * the live writer does, so a turn the reader watched in silence would still
38
+ * ship a full "Activity — N steps" row on its final if this were forgotten.
39
+ * One place, both consumers — the answer and the turn-exit error.
40
+ */
41
+ export declare function buildCodexFinalTurnTrail(input: {
42
+ blocks: ReadonlyArray<TurnOutputBlock>;
43
+ turnId: string | null;
44
+ turnVerbosity: TurnVerbosity;
45
+ }): TurnOutputBlock[];
32
46
  export {};
@@ -1,4 +1,5 @@
1
1
  import { buildTrailBlockId, normalizeTrailKey } from '@canonmsg/coding-agent-host';
2
+ import { buildBoundedTurnTrail, shouldPublishTurnTrail } from '@canonmsg/core';
2
3
  export function createCommandBlockTracker() {
3
4
  return {
4
5
  sequence: 0,
@@ -87,3 +88,17 @@ export function claimCommandBlock(tracker, input) {
87
88
  }
88
89
  return nextCommandBlockId(tracker, input.turnId, itemId);
89
90
  }
91
+ /**
92
+ * The margin trail to hang on this turn's final.
93
+ *
94
+ * The quiet gate lives here, separate from the live-node guard in the host,
95
+ * because the two are genuinely separate decisions: blocks accumulate whatever
96
+ * the live writer does, so a turn the reader watched in silence would still
97
+ * ship a full "Activity — N steps" row on its final if this were forgotten.
98
+ * One place, both consumers — the answer and the turn-exit error.
99
+ */
100
+ export function buildCodexFinalTurnTrail(input) {
101
+ if (!shouldPublishTurnTrail(input.turnVerbosity))
102
+ return [];
103
+ return buildBoundedTurnTrail(input.blocks.map((block) => ({ ...block, turnId: input.turnId ?? block.turnId })));
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.23.7",
3
+ "version": "0.24.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,9 +29,9 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^8.1.0",
32
+ "@canonmsg/agent-sdk": "^8.2.0",
33
33
  "@canonmsg/coding-agent-host": "^0.4.0",
34
- "@canonmsg/core": "^9.2.0"
34
+ "@canonmsg/core": "^10.0.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"