@canonmsg/codex-plugin 0.23.7 → 0.25.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,6 @@
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 TurnArtifactRoutingDecision, type TurnArtifactRoutingMode } from '@canonmsg/coding-agent-host';
3
+ 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
4
  import { type CodexSkillMetadata } from './app-server-adapter.js';
4
5
  import { type CodexControlOption } from './model-catalog.js';
5
6
  interface HostSessionState {
@@ -67,5 +68,102 @@ export declare function getCodexRequestingUserId(message: {
67
68
  senderId: string;
68
69
  senderType?: 'human' | 'ai_agent';
69
70
  }): string | null;
71
+ /**
72
+ * `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
73
+ * per-conversation-type default".
74
+ *
75
+ * An unparseable value is reported and ignored rather than fatal. This host's
76
+ * `parseArgs` is `strict: true`, so an unknown FLAG already stops the process —
77
+ * that is the check worth having. A wrong VALUE is a presentation choice, and
78
+ * taking a local agent offline over one would be a worse failure than showing
79
+ * the default. An empty declaration (`ENV=` in a Dockerfile, `--turn-verbosity
80
+ * ''`) is absence, not a mistake, so it says nothing.
81
+ */
82
+ export declare function resolveConfiguredCodexTurnVerbosity(input: {
83
+ flag?: unknown;
84
+ env?: string | undefined;
85
+ onWarning?: (message: string) => void;
86
+ }): TurnVerbosityConfig | null;
87
+ /**
88
+ * The turn state a quiet turn publishes to `/turn-state`.
89
+ *
90
+ * Current clients render "is thinking" for every open non-waiting state, so
91
+ * the chat header is unchanged. It exists for app binaries older than #602,
92
+ * whose typing-dot filter suppressed an agent on turn state `streaming`/`tool`
93
+ * because a live bubble was expected to carry the state instead — and a quiet
94
+ * turn has no bubble. The one current consumer that reads the difference is the
95
+ * direct-chat session strip, which labels `streaming` "Streaming" / "Live
96
+ * preview"; a direct chat started with an explicit quiet therefore reads
97
+ * "Thinking" for the whole turn, which is what it is. `waiting_input` is left
98
+ * alone: it changes what the header says and how the clients treat the dots,
99
+ * and a turn blocked on a human is not thinking.
100
+ */
101
+ export declare function publishedCodexTurnState(state: TurnLifecycleState, turnVerbosity: TurnVerbosity): TurnLifecycleState;
102
+ /**
103
+ * The `/streaming` write for one live-node update, or `null` for none.
104
+ *
105
+ * Extracted from `writeCodexStreaming` so the quiet gate covering all eight
106
+ * call sites — the turn-open seed, assistant text, the plan update (which
107
+ * passes its text as the node's SPEECH, the loudest step emission this host
108
+ * has), waiting, command start/completion, and the two card transitions — is a
109
+ * unit a test can hold, rather than one inline comparison a refactor can drop
110
+ * in silence.
111
+ *
112
+ * `liveText` is returned in BOTH modes and is deliberately outside the gate:
113
+ * every downstream reader, including the final trail and the turn-exit error,
114
+ * still has to see what the turn produced. A quiet turn publishes no node at
115
+ * all rather than a status-only one, so `onStreamingCleared` has nothing to
116
+ * salvage into a durable bubble if the turn dies mid-flight.
117
+ */
118
+ export declare function planCodexStreamingWrite(input: {
119
+ turnVerbosity: TurnVerbosity;
120
+ /** The new live text, or `null` to keep what the turn already had. */
121
+ text: string | null;
122
+ liveText: string;
123
+ status: RuntimeStreamingPayload['status'];
124
+ turnId: string | null;
125
+ blocks: TurnOutputBlock[];
126
+ }): {
127
+ liveText: string;
128
+ write: RuntimeStreamingPayload | null;
129
+ };
130
+ /**
131
+ * When streamed text starts arriving, do the typing dots retire?
132
+ *
133
+ * In verbose they do, and should: the live bubble becomes the indicator, and
134
+ * two indicators for one turn is noise. A quiet turn has no bubble, so the
135
+ * dots are the only thing the reader has for the whole generation phase — the
136
+ * longest stretch of the turn. Shared with the Claude host, which spells the
137
+ * same decision at its `text_delta` handler.
138
+ */
139
+ export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
140
+ /**
141
+ * Whether this turn may post the media it generated in the workspace.
142
+ *
143
+ * Where the shared ruling (`resolveTurnArtifactRouting`) meets this host's
144
+ * per-turn state, and the only place silence is resolved for Codex artifacts:
145
+ * `silenced` here is the RAW `no_reply` sentinel, and it is weighed against the
146
+ * turn's own final text by `isSilentTurnSuppressed` — the same switch the final
147
+ * delivery reads. Flipping `DEFAULT_SILENT_TURN_PRECEDENCE` to `advisory`
148
+ * therefore moves a turn's text and its files together; it cannot leave Codex
149
+ * talking about a chart it then withholds.
150
+ *
151
+ * `finalText` is the model's own reply, never Canon's failure notice: the
152
+ * notice is a host diagnostic and goes out whether the turn spoke or not.
153
+ *
154
+ * Interruption is not a parameter. Codex's `result.interrupted` branch is the
155
+ * one completion branch that never calls the funnel, so the axis is dead on
156
+ * the normal paths. The known gap is the catch branch, which routes
157
+ * unconditionally and IS reachable after an interrupt (a hard interrupt can
158
+ * make `runTurn` reject rather than resolve); an interrupted turn's artifacts
159
+ * can still be posted alongside the failure notice there, as they always
160
+ * could. Claude gates that case; matching it needs an interrupt signal Codex
161
+ * does not currently carry into the catch without a race.
162
+ */
163
+ export declare function shouldRouteCodexTurnArtifacts(input: {
164
+ artifactRoutingMode: TurnArtifactRoutingMode | undefined;
165
+ silenced: boolean;
166
+ finalText: string | null | undefined;
167
+ }): TurnArtifactRoutingDecision;
70
168
  export declare function main(): Promise<void>;
71
169
  export {};
package/dist/host.js CHANGED
@@ -5,11 +5,11 @@ import { spawnSync } from 'node:child_process';
5
5
  import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
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';
8
+ import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, 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, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, isSilentTurnSuppressed, 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,123 @@ 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
+ }
541
+ /**
542
+ * Whether this turn may post the media it generated in the workspace.
543
+ *
544
+ * Where the shared ruling (`resolveTurnArtifactRouting`) meets this host's
545
+ * per-turn state, and the only place silence is resolved for Codex artifacts:
546
+ * `silenced` here is the RAW `no_reply` sentinel, and it is weighed against the
547
+ * turn's own final text by `isSilentTurnSuppressed` — the same switch the final
548
+ * delivery reads. Flipping `DEFAULT_SILENT_TURN_PRECEDENCE` to `advisory`
549
+ * therefore moves a turn's text and its files together; it cannot leave Codex
550
+ * talking about a chart it then withholds.
551
+ *
552
+ * `finalText` is the model's own reply, never Canon's failure notice: the
553
+ * notice is a host diagnostic and goes out whether the turn spoke or not.
554
+ *
555
+ * Interruption is not a parameter. Codex's `result.interrupted` branch is the
556
+ * one completion branch that never calls the funnel, so the axis is dead on
557
+ * the normal paths. The known gap is the catch branch, which routes
558
+ * unconditionally and IS reachable after an interrupt (a hard interrupt can
559
+ * make `runTurn` reject rather than resolve); an interrupted turn's artifacts
560
+ * can still be posted alongside the failure notice there, as they always
561
+ * could. Claude gates that case; matching it needs an interrupt signal Codex
562
+ * does not currently carry into the catch without a race.
563
+ */
564
+ export function shouldRouteCodexTurnArtifacts(input) {
565
+ return resolveTurnArtifactRouting({
566
+ artifactRoutingMode: input.artifactRoutingMode,
567
+ silenced: isSilentTurnSuppressed({ silenced: input.silenced, finalText: input.finalText }),
568
+ });
569
+ }
443
570
  export async function main() {
444
571
  setDefaultResultOrder('ipv4first');
445
572
  const { values: args } = parseArgs({
@@ -457,6 +584,7 @@ export async function main() {
457
584
  'runtime-visibility': { type: 'string' },
458
585
  'show-runtime-detail': { type: 'string', multiple: true },
459
586
  'hide-runtime-detail': { type: 'string', multiple: true },
587
+ 'turn-verbosity': { type: 'string' },
460
588
  'full-auto': { type: 'boolean' },
461
589
  'dangerously-bypass-approvals-and-sandbox': { type: 'boolean' },
462
590
  },
@@ -467,6 +595,11 @@ export async function main() {
467
595
  process.exitCode = 1;
468
596
  return;
469
597
  }
598
+ configuredTurnVerbosity = resolveConfiguredCodexTurnVerbosity({
599
+ flag: args['turn-verbosity'],
600
+ env: process.env.CANON_TURN_VERBOSITY,
601
+ onWarning: (message) => console.error(`[canon-codex] ${message}`),
602
+ });
470
603
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
471
604
  const workspaceDiscovery = buildConfiguredWorkspaceOptionsWithRoots({
472
605
  primaryCwd: workingDir,
@@ -748,7 +881,7 @@ export async function main() {
748
881
  || session.turnState === 'waiting_input';
749
882
  runtimeState.writeTurnState(session.conversationId, {
750
883
  turnId: session.currentTurnId,
751
- state: session.turnState,
884
+ state: publishedCodexTurnState(session.turnState, session.turnVerbosity),
752
885
  queueDepth: session.queue.length,
753
886
  currentSpeakerId: agentId,
754
887
  lastAcceptedIntent: session.lastAcceptedIntent,
@@ -824,15 +957,39 @@ export async function main() {
824
957
  runtimeState.clearStreaming(conversationId).catch(() => { });
825
958
  }
826
959
  function writeCodexStreaming(session, text, status) {
827
- if (text !== null) {
828
- session.turnLiveText = text;
829
- }
830
- runtimeState.writeStreaming(session.conversationId, {
831
- text: session.turnLiveText,
960
+ const plan = planCodexStreamingWrite({
961
+ turnVerbosity: session.turnVerbosity,
962
+ text,
963
+ liveText: session.turnLiveText,
832
964
  status,
833
- messageId: session.currentTurnId ?? undefined,
834
965
  turnId: session.currentTurnId,
835
966
  blocks: session.turnBlocks,
967
+ });
968
+ session.turnLiveText = plan.liveText;
969
+ if (!plan.write)
970
+ return;
971
+ runtimeState.writeStreaming(session.conversationId, plan.write).catch(() => { });
972
+ }
973
+ /**
974
+ * Blanks the live node for a turn that chose silence. Awaited, unlike
975
+ * `writeCodexStreaming`: the delete that follows must not race ahead of this
976
+ * write, or the trigger salvages the un-blanked value into a durable message.
977
+ * The empty `blocks` array is load-bearing — `fallbackTextFromBlocks`
978
+ * rebuilds salvage text out of tool-trail titles when only `text` is cleared.
979
+ *
980
+ * Left unconditional under quiet, where there is nothing to scrub: the write
981
+ * costs one no-op round trip on a rare path, and keeping the deliberate-
982
+ * silence sequence identical in both modes is worth more than saving it.
983
+ */
984
+ async function blankCodexStreaming(session) {
985
+ session.turnLiveText = '';
986
+ session.turnBlocks = [];
987
+ await runtimeState.writeStreaming(session.conversationId, {
988
+ text: '',
989
+ status: 'thinking',
990
+ messageId: session.currentTurnId ?? undefined,
991
+ turnId: session.currentTurnId,
992
+ blocks: [],
836
993
  }).catch(() => { });
837
994
  }
838
995
  function upsertCodexTextSegment(session, event) {
@@ -882,10 +1039,11 @@ export async function main() {
882
1039
  });
883
1040
  }
884
1041
  function buildFinalTurnTrail(session) {
885
- return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
886
- ...block,
887
- turnId: session.currentTurnId ?? block.turnId,
888
- })));
1042
+ return buildCodexFinalTurnTrail({
1043
+ blocks: session.turnBlocks,
1044
+ turnId: session.currentTurnId,
1045
+ turnVerbosity: session.turnVerbosity,
1046
+ });
889
1047
  }
890
1048
  function buildCodexMessageId(session, kind) {
891
1049
  return `codex-${kind}-${session.currentTurnId ?? randomUUID()}`;
@@ -908,6 +1066,21 @@ export async function main() {
908
1066
  function startVisibleWorkSignal(session) {
909
1067
  refreshVisibleWorkSignal(session);
910
1068
  }
1069
+ /**
1070
+ * Start the dots whatever the turn state says.
1071
+ *
1072
+ * `refreshVisibleWorkSignal` filters on `thinking`/`tool`, which is right for
1073
+ * a verbose turn — once text starts flowing the live bubble is the indicator,
1074
+ * so the dots are meant to retire. A quiet turn has no bubble, so the dots
1075
+ * have to survive the flip to `streaming`, and the filtered helper would
1076
+ * silently do nothing. Cheap to call repeatedly: the publisher skips the
1077
+ * write when it is already active with the same status.
1078
+ */
1079
+ function ensureVisibleWorkSignal(session) {
1080
+ if (!session.running || session.closed)
1081
+ return;
1082
+ typingSignals.start(session.conversationId, 'thinking').catch(() => { });
1083
+ }
911
1084
  function stopVisibleWorkSignal(session) {
912
1085
  if (session.typingKeepaliveTimer) {
913
1086
  clearInterval(session.typingKeepaliveTimer);
@@ -915,6 +1088,29 @@ export async function main() {
915
1088
  }
916
1089
  typingSignals.clear(session.conversationId).catch(() => { });
917
1090
  }
1091
+ /**
1092
+ * A turn parked on an approval, an input request or a card is working again
1093
+ * the moment the answer arrives.
1094
+ *
1095
+ * Codex announces the park — a `thread/status/changed` carrying
1096
+ * `waitingOnApproval`/`waitingOnUserInput`, which the adapter forwards as
1097
+ * `waiting` — but announces no resume, so the host has to draw that edge
1098
+ * itself. Without it `/turn-state` keeps saying "waiting for your reply"
1099
+ * while the agent generates, and the dots stopped at the park never come
1100
+ * back: the clients suppress an agent's dots on `waiting_input`, and the
1101
+ * events that follow a resume (`message`, `plan.updated`) set the state to
1102
+ * `streaming`, which `refreshVisibleWorkSignal` skips. The Claude host draws
1103
+ * the same edge off its `session_state_changed: running` echo.
1104
+ */
1105
+ function resumeTurnFromWaiting(session) {
1106
+ if (session.turnState !== 'waiting_input')
1107
+ return;
1108
+ session.turnState = 'thinking';
1109
+ markTurnProgress(session);
1110
+ writeTurn(session);
1111
+ startVisibleWorkSignal(session);
1112
+ writeCodexStreaming(session, null, 'thinking');
1113
+ }
918
1114
  function closeSession(conversationId) {
919
1115
  const session = sessions.get(conversationId);
920
1116
  if (!session)
@@ -952,6 +1148,7 @@ export async function main() {
952
1148
  session.currentTurnOpenedAt = null;
953
1149
  session.currentTurnUpdatedAt = null;
954
1150
  session.currentTurnCanUseCodexAppTools = false;
1151
+ session.currentTurnSilenced = false;
955
1152
  session.lastAcceptedIntent = null;
956
1153
  session.resetRequested = false;
957
1154
  }
@@ -1062,6 +1259,10 @@ export async function main() {
1062
1259
  currentTurnOpenedAt: null,
1063
1260
  currentTurnUpdatedAt: null,
1064
1261
  currentTurnCanUseCodexAppTools: false,
1262
+ // Corrected by the first turn that runs; a session with no turn
1263
+ // publishes nothing anyway, and quiet is never an accident.
1264
+ turnVerbosity: 'verbose',
1265
+ currentTurnSilenced: false,
1065
1266
  activeSelfContextId: null,
1066
1267
  lastAcceptedIntent: null,
1067
1268
  pendingDroppedRecoveryCursor: null,
@@ -1093,7 +1294,7 @@ export async function main() {
1093
1294
  pendingSessionCreations.delete(conversationId);
1094
1295
  }
1095
1296
  }
1096
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false, requestingUserId = null) {
1297
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, turn = {}) {
1097
1298
  const nextPrompt = {
1098
1299
  prompt,
1099
1300
  intent,
@@ -1102,9 +1303,10 @@ export async function main() {
1102
1303
  imagePaths,
1103
1304
  mediaAddDirs,
1104
1305
  planMode,
1105
- artifactRoutingMode,
1106
- canUseCodexAppTools,
1107
- requestingUserId,
1306
+ artifactRoutingMode: turn.artifactRoutingMode ?? 'disabled',
1307
+ canUseCodexAppTools: turn.canUseCodexAppTools ?? false,
1308
+ ...(turn.turnVerbosity ? { turnVerbosity: turn.turnVerbosity } : {}),
1309
+ requestingUserId: turn.requestingUserId ?? null,
1108
1310
  recoverySequence: ++inboundRecoverySequence,
1109
1311
  };
1110
1312
  if (toFront) {
@@ -1128,13 +1330,33 @@ export async function main() {
1128
1330
  : result.status === 'reject'
1129
1331
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1130
1332
  : `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);
1333
+ // No `turnVerbosity`: this prompt continues the same conversation, so it
1334
+ // keeps whatever the session last resolved rather than reverting to the
1335
+ // default.
1336
+ enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', { requestingUserId: responseUserId });
1132
1337
  }
1133
1338
  function resolveArtifactRoutingMode(participantContext) {
1134
1339
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1135
1340
  ? 'workspace-generated'
1136
1341
  : 'disabled';
1137
1342
  }
1343
+ /**
1344
+ * Everything about a turn that is decided from the message that started it,
1345
+ * in one place. Resolved at the enqueue site and carried on the queue entry,
1346
+ * so a prompt that waits behind another still runs under the answer it
1347
+ * arrived with.
1348
+ */
1349
+ function resolveCodexTurnModes(participantContext, message) {
1350
+ return {
1351
+ artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
1352
+ canUseCodexAppTools: participantContext.isOwner,
1353
+ turnVerbosity: resolveTurnVerbosity({
1354
+ configured: configuredTurnVerbosity,
1355
+ conversationType: participantContext.conversationType,
1356
+ }),
1357
+ requestingUserId: getCodexRequestingUserId(message),
1358
+ };
1359
+ }
1138
1360
  function runtimeCardRequestPayload(method, params) {
1139
1361
  if (method !== 'item/runtimeCard/request'
1140
1362
  && method !== 'runtimeCard/request') {
@@ -1149,12 +1371,28 @@ export async function main() {
1149
1371
  const params = request.params;
1150
1372
  const expiresAt = Date.now() + 30 * 60_000;
1151
1373
  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.');
1374
+ // The admission order — no_reply above the owner gate — is pinned by
1375
+ // `classifyCodexAppToolRequest`'s tests, not by the shape of this block.
1376
+ const disposition = classifyCodexAppToolRequest({
1377
+ params,
1378
+ transportSupportsAppTools: session.adapter instanceof CodexAppServerAdapter,
1379
+ canUseAppTools: session.currentTurnCanUseCodexAppTools,
1380
+ });
1381
+ if (disposition === 'no-reply') {
1382
+ session.currentTurnSilenced = true;
1383
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn chose no_reply`
1384
+ + ` (reason: ${readCodexNoReplyReason(params) ? 'given' : 'none'})`);
1385
+ return codexNoReplyToolResult();
1154
1386
  }
1155
- if (!session.currentTurnCanUseCodexAppTools) {
1387
+ if (disposition === 'denied-non-owner') {
1156
1388
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1157
1389
  }
1390
+ // `disposition` already ruled on the transport (it is checked first, so a
1391
+ // wrong transport never reaches the branches above). This repeats the
1392
+ // test only to narrow the adapter type for the bridge call.
1393
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1394
+ return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1395
+ }
1158
1396
  return await handleCodexAppToolCall({
1159
1397
  adapter: session.adapter,
1160
1398
  currentThreadId: session.adapter.getThreadId(),
@@ -1234,13 +1472,7 @@ export async function main() {
1234
1472
  },
1235
1473
  });
1236
1474
  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
- }
1475
+ resumeTurnFromWaiting(session);
1244
1476
  return response;
1245
1477
  }
1246
1478
  catch (error) {
@@ -1298,6 +1530,7 @@ export async function main() {
1298
1530
  },
1299
1531
  turnId: session.currentTurnId ?? undefined,
1300
1532
  }, { requestId: inputId, expiresAt });
1533
+ resumeTurnFromWaiting(session);
1301
1534
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1302
1535
  }
1303
1536
  const mappedApproval = mapCodexAppServerApprovalRequest({
@@ -1316,6 +1549,7 @@ export async function main() {
1316
1549
  : {}),
1317
1550
  allowSessionRule: responseRouting.allowSessionRule,
1318
1551
  }, { requestId: approvalId, expiresAt });
1552
+ resumeTurnFromWaiting(session);
1319
1553
  if (request.method === 'item/permissions/requestApproval') {
1320
1554
  return response.decision === 'allow'
1321
1555
  ? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
@@ -1354,7 +1588,10 @@ export async function main() {
1354
1588
  : decision === 'reject'
1355
1589
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1356
1590
  : `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));
1591
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', {
1592
+ canUseCodexAppTools: input.isOwner,
1593
+ requestingUserId: getCodexRequestingUserId(input.message),
1594
+ });
1358
1595
  return;
1359
1596
  }
1360
1597
  let materialized = [];
@@ -1450,18 +1687,17 @@ export async function main() {
1450
1687
  provenance: hydrated.provenance,
1451
1688
  replyContext,
1452
1689
  message: input.message,
1690
+ ...(useAppServer ? { noReplyToolName: CODEX_NO_REPLY_MODEL_TOOL_NAME } : {}),
1453
1691
  });
1454
1692
  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));
1693
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1457
1694
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1458
1695
  await session.adapter.interrupt().catch(() => { });
1459
1696
  clearStreaming(input.conversationId);
1460
1697
  typingSignals.clear(input.conversationId).catch(() => { });
1461
1698
  return;
1462
1699
  }
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));
1700
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
1465
1701
  }
1466
1702
  function sendTurnArtifactFile(session, file) {
1467
1703
  return sendMediaFileMessage(client, session.conversationId, file.path, '', {
@@ -1476,34 +1712,6 @@ export async function main() {
1476
1712
  },
1477
1713
  });
1478
1714
  }
1479
- async function routeWorkspaceGeneratedArtifacts(session, baseline) {
1480
- if (!baseline)
1481
- return;
1482
- const logPrefix = `[canon-codex] [${session.conversationId.slice(0, 8)}]`;
1483
- try {
1484
- const result = await collectTurnArtifacts({
1485
- cwd: session.cwd,
1486
- baseline,
1487
- });
1488
- for (const file of result.files) {
1489
- try {
1490
- const { messageId } = await sendTurnArtifactFile(session, file);
1491
- console.error(`${logPrefix} Routed generated artifact ${file.relativePath ?? file.fileName} (${messageId})`);
1492
- }
1493
- catch (error) {
1494
- console.error(`${logPrefix} Artifact upload failed for ${file.relativePath ?? file.fileName}: ${error instanceof Error ? error.message : String(error)}`);
1495
- }
1496
- }
1497
- for (const skipped of result.skipped) {
1498
- if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'scan-limit') {
1499
- console.error(`${logPrefix} Artifact skipped ${skipped.fileName} (${skipped.reason})`);
1500
- }
1501
- }
1502
- }
1503
- catch (error) {
1504
- console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
1505
- }
1506
- }
1507
1715
  async function runNextTurn(session) {
1508
1716
  if (session.running || session.closed)
1509
1717
  return;
@@ -1520,6 +1728,10 @@ export async function main() {
1520
1728
  session.currentTurnOpenedAt = Date.now();
1521
1729
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1522
1730
  session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
1731
+ // A continuation prompt (a plan-review result) carries none, and keeps the
1732
+ // conversation's last answer rather than silently reverting to verbose.
1733
+ session.turnVerbosity = nextTurn.turnVerbosity ?? session.turnVerbosity;
1734
+ session.currentTurnSilenced = false;
1523
1735
  session.lastAcceptedIntent = nextTurn.intent;
1524
1736
  session.turnState = 'thinking';
1525
1737
  session.lastActivity = Date.now();
@@ -1527,19 +1739,46 @@ export async function main() {
1527
1739
  writeState(session);
1528
1740
  writeTurn(session);
1529
1741
  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');
1742
+ if (session.turnVerbosity === 'quiet') {
1743
+ // A quiet turn publishes nothing, so it cannot rely on the seed below to
1744
+ // overwrite a node an earlier verbose turn left behind — and a surviving
1745
+ // node carrying the PREVIOUS turn's id makes the clients drop this turn's
1746
+ // live row entirely. Delete instead. What `onStreamingCleared` does with
1747
+ // that delete is the same in both modes and is the ordering we want: a
1748
+ // node that is already gone has nothing to fire on, one left by a turn
1749
+ // that delivered its final is skipped as `turn_complete_exists`, and one
1750
+ // left by a turn that CRASHED mid-narration is salvaged into a durable
1751
+ // message — here, at turn open, before this turn's answer, rather than
1752
+ // arriving after it.
1753
+ clearStreaming(session.conversationId);
1754
+ }
1755
+ else {
1756
+ // Status-only seed: 'thinking' renders as a working filament row on the
1757
+ // clients; text here would be bubbled as speech (v4 register rule).
1758
+ writeCodexStreaming(session, '', 'thinking');
1759
+ }
1533
1760
  let artifactBaseline = null;
1534
- let artifactsRouted = false;
1535
- const routeArtifactsOnce = async () => {
1536
- if (artifactsRouted)
1537
- return;
1538
- artifactsRouted = true;
1539
- if (nextTurn.artifactRoutingMode === 'workspace-generated') {
1540
- await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
1541
- }
1542
- };
1761
+ // The model's own reply for this turn, once it is known. Read by the gate
1762
+ // below so silence is weighed against the SAME text the final delivery
1763
+ // weighs it against; null while the turn runs and after a throw, which is
1764
+ // the honest answer — no reply was produced.
1765
+ let turnFinalText = null;
1766
+ // Every completion branch below funnels through this router, and it owns
1767
+ // the collect-and-post loop, so there is no un-gated path left to the
1768
+ // workspace: the failure branches still send Canon's notice, and none of
1769
+ // them can post the artifacts of a turn that chose silence.
1770
+ const artifactRouter = createTurnArtifactRouter({
1771
+ decide: () => shouldRouteCodexTurnArtifacts({
1772
+ artifactRoutingMode: nextTurn.artifactRoutingMode,
1773
+ silenced: session.currentTurnSilenced,
1774
+ finalText: turnFinalText,
1775
+ }),
1776
+ baseline: () => artifactBaseline,
1777
+ cwd: () => session.cwd,
1778
+ send: (file) => sendTurnArtifactFile(session, file),
1779
+ log: (line) => console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`),
1780
+ });
1781
+ const routeArtifactsOnce = artifactRouter.route;
1543
1782
  try {
1544
1783
  const turnId = session.currentTurnId ?? randomUUID();
1545
1784
  session.currentTurnId = turnId;
@@ -1581,7 +1820,16 @@ export async function main() {
1581
1820
  session.turnState = 'streaming';
1582
1821
  markTurnProgress(session);
1583
1822
  writeTurn(session);
1584
- stopVisibleWorkSignal(session);
1823
+ // The dots stop here only because the live bubble takes over as the
1824
+ // indicator. A quiet turn has no bubble, so they have to keep
1825
+ // running — and `ensure`, not merely "don't stop": a turn resuming
1826
+ // from an approval had them cleared at the park.
1827
+ if (shouldStopTypingDotsOnStreamedText(session.turnVerbosity)) {
1828
+ stopVisibleWorkSignal(session);
1829
+ }
1830
+ else {
1831
+ ensureVisibleWorkSignal(session);
1832
+ }
1585
1833
  upsertCodexTextSegment(session, event);
1586
1834
  writeCodexStreaming(session, null, 'streaming');
1587
1835
  return;
@@ -1590,7 +1838,12 @@ export async function main() {
1590
1838
  session.turnState = 'streaming';
1591
1839
  markTurnProgress(session);
1592
1840
  writeTurn(session);
1593
- stopVisibleWorkSignal(session);
1841
+ if (shouldStopTypingDotsOnStreamedText(session.turnVerbosity)) {
1842
+ stopVisibleWorkSignal(session);
1843
+ }
1844
+ else {
1845
+ ensureVisibleWorkSignal(session);
1846
+ }
1594
1847
  upsertTurnBlock(session, {
1595
1848
  // Spelled through the shared helper, and with the same fallback
1596
1849
  // the other host uses: a plan arriving before a turn id would
@@ -1681,8 +1934,17 @@ export async function main() {
1681
1934
  && isRecoverableCodexThreadError(result.errorText)) {
1682
1935
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
1683
1936
  clearStoredThread();
1937
+ // The retry is a whole fresh model turn, so it starts from a fresh
1938
+ // flag — exactly like turn start does. Without this, a `no_reply` from
1939
+ // the attempt that broke would silence a reply the retry did intend
1940
+ // to send, with no diagnostic anywhere.
1941
+ session.currentTurnSilenced = false;
1684
1942
  result = await runTurnOnce();
1685
1943
  }
1944
+ // Both the artifact gate and the final delivery weigh silence against
1945
+ // this text, and they must weigh the same one — set it before any
1946
+ // completion branch runs, including the ones that route first.
1947
+ turnFinalText = result.finalMessage ?? null;
1686
1948
  if (session.adapter instanceof CodexAppServerAdapter) {
1687
1949
  const resolvedModel = session.adapter.getResolvedModel();
1688
1950
  const resolvedEffort = session.adapter.getResolvedReasoningEffort();
@@ -1697,7 +1959,17 @@ export async function main() {
1697
1959
  if (result.threadId && !session.resetRequested) {
1698
1960
  saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1699
1961
  }
1700
- if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1962
+ if (!result.interrupted
1963
+ && result.finalMessage
1964
+ && nextTurn.planMode
1965
+ // A plan card carries the model's final text into the conversation as a
1966
+ // visible, actionable artifact — it IS posting. Silence is strict here
1967
+ // too: a silenced plan turn falls through to the silent teardown below
1968
+ // and raises no card.
1969
+ && resolveSilentTurnDelivery({
1970
+ silenced: session.currentTurnSilenced,
1971
+ finalText: result.finalMessage,
1972
+ }) === 'deliver') {
1701
1973
  await routeArtifactsOnce();
1702
1974
  const responseRouting = buildCodexTurnResponseRouting({
1703
1975
  requestingUserId: nextTurn.requestingUserId,
@@ -1743,7 +2015,15 @@ export async function main() {
1743
2015
  await handoffFinalMessage(session.conversationId);
1744
2016
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1745
2017
  }
1746
- else if (!result.interrupted && result.finalMessage) {
2018
+ else if (!result.interrupted
2019
+ && result.finalMessage
2020
+ // A turn that called `no_reply` posts nothing. The failure branches
2021
+ // below are deliberately outside this gate: silence suppresses the
2022
+ // MODEL's reply, never Canon's own "this turn broke" diagnostic.
2023
+ && resolveSilentTurnDelivery({
2024
+ silenced: session.currentTurnSilenced,
2025
+ finalText: result.finalMessage,
2026
+ }) === 'deliver') {
1747
2027
  if (isRecoverableCodexThreadError(result.errorText)) {
1748
2028
  clearStoredThread();
1749
2029
  }
@@ -1791,6 +2071,22 @@ export async function main() {
1791
2071
  }
1792
2072
  else if (!result.interrupted) {
1793
2073
  await routeArtifactsOnce();
2074
+ if (session.currentTurnSilenced) {
2075
+ // Same thread hygiene the delivering branch does: a silent turn that
2076
+ // also hit a recoverable thread error must not leave the dead thread
2077
+ // id behind for the next turn to resume.
2078
+ if (isRecoverableCodexThreadError(result.errorText)) {
2079
+ clearStoredThread();
2080
+ }
2081
+ // Deliberate silence: blank the node (text '' AND an explicit empty
2082
+ // blocks array) before deleting it, or onStreamingCleared salvages
2083
+ // this turn's narration into a durable bubble. The live row and the
2084
+ // typing dots go together — leaving dots behind the removed row for
2085
+ // the handoff window reads as "started to answer, then gave up".
2086
+ await blankCodexStreaming(session);
2087
+ clearStreaming(session.conversationId);
2088
+ stopVisibleWorkSignal(session);
2089
+ }
1794
2090
  await handoffFinalMessage(session.conversationId);
1795
2091
  }
1796
2092
  else if (result.interrupted) {
@@ -1846,6 +2142,7 @@ export async function main() {
1846
2142
  session.currentTurnOpenedAt = null;
1847
2143
  session.currentTurnUpdatedAt = null;
1848
2144
  session.currentTurnCanUseCodexAppTools = false;
2145
+ session.currentTurnSilenced = false;
1849
2146
  session.lastAcceptedIntent = null;
1850
2147
  session.resetRequested = false;
1851
2148
  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.25.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",
33
- "@canonmsg/coding-agent-host": "^0.4.0",
34
- "@canonmsg/core": "^9.2.0"
32
+ "@canonmsg/agent-sdk": "^8.2.0",
33
+ "@canonmsg/coding-agent-host": "^0.5.0",
34
+ "@canonmsg/core": "^10.1.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"