@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 +37 -1
- package/dist/codex-app-tools.d.ts +40 -0
- package/dist/codex-app-tools.js +76 -0
- package/dist/host.d.ts +99 -1
- package/dist/host.js +377 -80
- package/dist/turn-activity.d.ts +15 -1
- package/dist/turn-activity.js +15 -0
- package/package.json +4 -4
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 {};
|
package/dist/codex-app-tools.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
828
|
-
session.
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
886
|
-
|
|
887
|
-
turnId: session.currentTurnId
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1153
|
-
|
|
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 (
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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();
|
package/dist/turn-activity.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/turn-activity.js
CHANGED
|
@@ -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.
|
|
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.
|
|
33
|
-
"@canonmsg/coding-agent-host": "^0.
|
|
34
|
-
"@canonmsg/core": "^
|
|
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"
|