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