@canonmsg/codex-plugin 0.25.0 → 0.26.1
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 +27 -0
- package/dist/app-server-adapter.js +25 -1
- package/dist/codex-app-tools.d.ts +36 -0
- package/dist/codex-app-tools.js +108 -18
- package/dist/host.d.ts +29 -1
- package/dist/host.js +186 -28
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -60,6 +60,33 @@ You do not need a git repo for host mode. Any readable working directory is vali
|
|
|
60
60
|
- Quiet group turns: in groups the host shows the thinking indicator and the
|
|
61
61
|
answer only; direct chats keep the live preview and margin activity
|
|
62
62
|
|
|
63
|
+
### Service-agent mode
|
|
64
|
+
|
|
65
|
+
Long-running customer agents can use the same Canon host without exposing its
|
|
66
|
+
coding controls:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
canon-codex \
|
|
70
|
+
--cwd /srv/agent-workspace \
|
|
71
|
+
--service-agent \
|
|
72
|
+
--no-native-vision
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`--service-agent` removes the coding-oriented dynamic tools and exposes only
|
|
76
|
+
`codex_app.canon_runtime_control` plus `codex_app.no_reply`. Runtime control is
|
|
77
|
+
bound by the host to the active Canon conversation and authenticated human; the
|
|
78
|
+
model cannot select another user, conversation, or responder. It can display a
|
|
79
|
+
card with `send_card`, wait for an action card with `request_card`, or ask one
|
|
80
|
+
standalone question with `request_input`.
|
|
81
|
+
|
|
82
|
+
Authorized conversation members may use a service agent even when they do not
|
|
83
|
+
own its Canon identity. Normal coding-host sessions keep their existing
|
|
84
|
+
owner-only execution boundary.
|
|
85
|
+
|
|
86
|
+
`--no-native-vision` keeps inbound attachment paths in the prompt while
|
|
87
|
+
suppressing Codex's native image input. This lets a service agent use its
|
|
88
|
+
purpose-built OCR tool as the only image-recognition path.
|
|
89
|
+
|
|
63
90
|
## Transports
|
|
64
91
|
|
|
65
92
|
The host picks a transport at startup and logs the choice as
|
|
@@ -382,11 +382,26 @@ export class CodexAppServerAdapter {
|
|
|
382
382
|
}
|
|
383
383
|
async handleServerRequest(request) {
|
|
384
384
|
try {
|
|
385
|
+
const params = isRecord(request.params) ? request.params : {};
|
|
386
|
+
// Detached child threads may use the ordinary coding-thread bridge, but
|
|
387
|
+
// Canon conversation controls belong only to the foreground human turn.
|
|
388
|
+
// A fork can inherit dynamic-tool declarations, so enforce this again at
|
|
389
|
+
// dispatch instead of relying only on the child thread's advertised list.
|
|
390
|
+
if (isForegroundCanonToolCall(params) && !this.isCurrentThreadNotification(params)) {
|
|
391
|
+
this.write({
|
|
392
|
+
id: request.id,
|
|
393
|
+
error: {
|
|
394
|
+
code: -32001,
|
|
395
|
+
message: 'Server request does not belong to the active Canon turn',
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
385
400
|
const result = this.currentRequestHandler
|
|
386
401
|
? await this.currentRequestHandler({
|
|
387
402
|
id: request.id,
|
|
388
403
|
method: request.method,
|
|
389
|
-
params
|
|
404
|
+
params,
|
|
390
405
|
})
|
|
391
406
|
: defaultServerRequestResult(request.method);
|
|
392
407
|
this.write({ id: request.id, result });
|
|
@@ -616,6 +631,15 @@ export class CodexAppServerAdapter {
|
|
|
616
631
|
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
617
632
|
}
|
|
618
633
|
}
|
|
634
|
+
function isForegroundCanonToolCall(params) {
|
|
635
|
+
const rawTool = readString(params, 'tool');
|
|
636
|
+
if (!rawTool)
|
|
637
|
+
return false;
|
|
638
|
+
const tool = rawTool.startsWith('codex_app.')
|
|
639
|
+
? rawTool.slice('codex_app.'.length)
|
|
640
|
+
: rawTool;
|
|
641
|
+
return tool === 'canon_runtime_control' || tool === 'no_reply';
|
|
642
|
+
}
|
|
619
643
|
function parseJson(line) {
|
|
620
644
|
try {
|
|
621
645
|
const parsed = JSON.parse(line);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type NoReplyReportClient } from '@canonmsg/core';
|
|
1
2
|
type JsonRecord = Record<string, unknown>;
|
|
2
3
|
interface DynamicToolSpec {
|
|
3
4
|
[key: string]: unknown;
|
|
@@ -42,6 +43,7 @@ type DynamicToolCallResponse = {
|
|
|
42
43
|
* the host's turn, not to the app-server bridge.
|
|
43
44
|
*/
|
|
44
45
|
export declare const CODEX_NO_REPLY_TOOL_NAME = "no_reply";
|
|
46
|
+
export declare const CANON_RUNTIME_CONTROL_TOOL_NAME = "canon_runtime_control";
|
|
45
47
|
/**
|
|
46
48
|
* What the model actually sees for the tool above, for prompt text that names
|
|
47
49
|
* it (the group posture cue). Only meaningful on the app-server transport —
|
|
@@ -49,10 +51,28 @@ export declare const CODEX_NO_REPLY_TOOL_NAME = "no_reply";
|
|
|
49
51
|
*/
|
|
50
52
|
export declare const CODEX_NO_REPLY_MODEL_TOOL_NAME = "codex_app.no_reply";
|
|
51
53
|
export declare const CODEX_APP_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
|
|
54
|
+
/** The complete model-visible surface for non-coding service agents. */
|
|
55
|
+
export declare const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
|
|
56
|
+
/**
|
|
57
|
+
* Tools exposed to detached Codex threads created through the bridge. Canon
|
|
58
|
+
* conversation controls belong only to the foreground turn that received the
|
|
59
|
+
* human message; a child thread has neither that turn nor its responder.
|
|
60
|
+
*/
|
|
61
|
+
export declare const CODEX_DETACHED_THREAD_DYNAMIC_TOOLS: ReadonlyArray<DynamicToolSpec>;
|
|
52
62
|
export declare function isCodexAppToolCall(params: Record<string, unknown>): boolean;
|
|
53
63
|
export declare function deniedCodexAppToolResult(reason: string): DynamicToolCallResponse;
|
|
64
|
+
export declare function successfulCodexAppToolResult(payload: unknown): DynamicToolCallResponse;
|
|
54
65
|
/** True when this dynamic-tool call is the deliberate-silence verb. */
|
|
55
66
|
export declare function isCodexNoReplyToolCall(params: CodexAppToolCallParams): boolean;
|
|
67
|
+
export declare function isCanonRuntimeControlToolCall(params: CodexAppToolCallParams): boolean;
|
|
68
|
+
export declare function isCodexServiceAgentToolCall(params: CodexAppToolCallParams): boolean;
|
|
69
|
+
export interface CanonRuntimeControlRequest {
|
|
70
|
+
action: 'send_card' | 'request_card' | 'request_input';
|
|
71
|
+
card?: unknown;
|
|
72
|
+
prompt?: string;
|
|
73
|
+
placeholder?: string;
|
|
74
|
+
}
|
|
75
|
+
export declare function parseCanonRuntimeControlRequest(params: CodexAppToolCallParams): CanonRuntimeControlRequest | null;
|
|
56
76
|
/** Private rationale, when the model supplied one. Logged, never rendered. */
|
|
57
77
|
export declare function readCodexNoReplyReason(params: CodexAppToolCallParams): string | undefined;
|
|
58
78
|
/**
|
|
@@ -60,6 +80,22 @@ export declare function readCodexNoReplyReason(params: CodexAppToolCallParams):
|
|
|
60
80
|
* `no_reply` ack sends so the two can never drift.
|
|
61
81
|
*/
|
|
62
82
|
export declare function codexNoReplyToolResult(): DynamicToolCallResponse;
|
|
83
|
+
/**
|
|
84
|
+
* Answer a `no_reply` dynamic-tool call end to end: mark the turn silent
|
|
85
|
+
* FIRST, then report the outcome to the server (fire-and-forget telemetry,
|
|
86
|
+
* `reportNoReplyOutcome` swallows every failure), then ack the model. The
|
|
87
|
+
* report can never change the turn: a rejecting or unreachable server leaves
|
|
88
|
+
* the flag and the ack exactly as they were. `sourceMessageId` is the
|
|
89
|
+
* binding's own record of the message that started this turn — never
|
|
90
|
+
* model-supplied.
|
|
91
|
+
*/
|
|
92
|
+
export declare function answerCodexNoReply(input: {
|
|
93
|
+
client: NoReplyReportClient;
|
|
94
|
+
conversationId: string;
|
|
95
|
+
sourceMessageId: string | null;
|
|
96
|
+
params: CodexAppToolCallParams;
|
|
97
|
+
markSilenced: () => void;
|
|
98
|
+
}): DynamicToolCallResponse;
|
|
63
99
|
/** How the host should answer one `codex_app` dynamic-tool call. */
|
|
64
100
|
export type CodexAppToolDisposition = 'no-reply' | 'denied-transport' | 'denied-non-owner' | 'bridge';
|
|
65
101
|
/**
|
package/dist/codex-app-tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NO_REPLY_ACK_NOTE } from '@canonmsg/core';
|
|
1
|
+
import { NO_REPLY_ACK_NOTE, reportNoReplyOutcome } from '@canonmsg/core';
|
|
2
2
|
const emptyObjectSchema = {
|
|
3
3
|
type: 'object',
|
|
4
4
|
properties: {},
|
|
@@ -89,12 +89,51 @@ function tool(name, description, inputSchema, deferLoading = true) {
|
|
|
89
89
|
* the host's turn, not to the app-server bridge.
|
|
90
90
|
*/
|
|
91
91
|
export const CODEX_NO_REPLY_TOOL_NAME = 'no_reply';
|
|
92
|
+
export const CANON_RUNTIME_CONTROL_TOOL_NAME = 'canon_runtime_control';
|
|
92
93
|
/**
|
|
93
94
|
* What the model actually sees for the tool above, for prompt text that names
|
|
94
95
|
* it (the group posture cue). Only meaningful on the app-server transport —
|
|
95
96
|
* the `exec --json` transport registers no dynamic tools.
|
|
96
97
|
*/
|
|
97
98
|
export const CODEX_NO_REPLY_MODEL_TOOL_NAME = `codex_app.${CODEX_NO_REPLY_TOOL_NAME}`;
|
|
99
|
+
const CANON_RUNTIME_CONTROL_TOOL = tool(CANON_RUNTIME_CONTROL_TOOL_NAME, 'Interact with the current Canon conversation. Send a display card, request one card response, '
|
|
100
|
+
+ 'or ask one short question. Canon binds the current conversation and authenticated responder; '
|
|
101
|
+
+ 'never ask for or invent routing ids.', {
|
|
102
|
+
type: 'object',
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
properties: {
|
|
105
|
+
action: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
enum: ['send_card', 'request_card', 'request_input'],
|
|
108
|
+
},
|
|
109
|
+
card: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
description: 'A canon.card.v1 document. Use request_card when it contains actions.',
|
|
112
|
+
},
|
|
113
|
+
prompt: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description: 'A short question for request_input.',
|
|
116
|
+
},
|
|
117
|
+
placeholder: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description: 'Optional example or formatting hint for request_input.',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
required: ['action'],
|
|
123
|
+
}, false);
|
|
124
|
+
const CODEX_NO_REPLY_TOOL = tool(CODEX_NO_REPLY_TOOL_NAME, 'End your turn without posting anything to the conversation. Use it in group '
|
|
125
|
+
+ 'chats when you have nothing to add — no message is created, so no other '
|
|
126
|
+
+ 'member or agent is triggered. Optional private reason (logged, never '
|
|
127
|
+
+ 'shown). After calling this, produce no further text.', {
|
|
128
|
+
type: 'object',
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
properties: {
|
|
131
|
+
reason: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
description: 'Never rendered; logged only.',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}, false);
|
|
98
137
|
export const CODEX_APP_DYNAMIC_TOOLS = [
|
|
99
138
|
tool('automation_update', 'Create, update, view, or delete Codex app automations. Canon exposes the name for compatibility, but does not manage Desktop automations.', {
|
|
100
139
|
type: 'object',
|
|
@@ -205,24 +244,23 @@ export const CODEX_APP_DYNAMIC_TOOLS = [
|
|
|
205
244
|
},
|
|
206
245
|
required: ['threadId', 'title'],
|
|
207
246
|
}),
|
|
247
|
+
CANON_RUNTIME_CONTROL_TOOL,
|
|
208
248
|
// 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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
type: 'object',
|
|
217
|
-
additionalProperties: false,
|
|
218
|
-
properties: {
|
|
219
|
-
reason: {
|
|
220
|
-
type: 'string',
|
|
221
|
-
description: 'Never rendered; logged only.',
|
|
222
|
-
},
|
|
223
|
-
},
|
|
224
|
-
}, false),
|
|
249
|
+
// the Codex transport gives us. It is answered by the Canon host itself.
|
|
250
|
+
CODEX_NO_REPLY_TOOL,
|
|
251
|
+
];
|
|
252
|
+
/** The complete model-visible surface for non-coding service agents. */
|
|
253
|
+
export const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS = [
|
|
254
|
+
CANON_RUNTIME_CONTROL_TOOL,
|
|
255
|
+
CODEX_NO_REPLY_TOOL,
|
|
225
256
|
];
|
|
257
|
+
/**
|
|
258
|
+
* Tools exposed to detached Codex threads created through the bridge. Canon
|
|
259
|
+
* conversation controls belong only to the foreground turn that received the
|
|
260
|
+
* human message; a child thread has neither that turn nor its responder.
|
|
261
|
+
*/
|
|
262
|
+
export const CODEX_DETACHED_THREAD_DYNAMIC_TOOLS = CODEX_APP_DYNAMIC_TOOLS.filter((entry) => (entry.name !== CANON_RUNTIME_CONTROL_TOOL_NAME
|
|
263
|
+
&& entry.name !== CODEX_NO_REPLY_TOOL_NAME));
|
|
226
264
|
const CODEX_APP_TOOL_NAMES = new Set(CODEX_APP_DYNAMIC_TOOLS.map((entry) => String(entry.name)));
|
|
227
265
|
const UNSUPPORTED_TOOLS = new Map([
|
|
228
266
|
['automation_update', 'Canon does not manage Codex Desktop automations.'],
|
|
@@ -246,10 +284,38 @@ export function isCodexAppToolCall(params) {
|
|
|
246
284
|
export function deniedCodexAppToolResult(reason) {
|
|
247
285
|
return toolResult(false, { error: reason });
|
|
248
286
|
}
|
|
287
|
+
export function successfulCodexAppToolResult(payload) {
|
|
288
|
+
return toolResult(true, payload);
|
|
289
|
+
}
|
|
249
290
|
/** True when this dynamic-tool call is the deliberate-silence verb. */
|
|
250
291
|
export function isCodexNoReplyToolCall(params) {
|
|
251
292
|
return normalizeToolName(params.tool) === CODEX_NO_REPLY_TOOL_NAME;
|
|
252
293
|
}
|
|
294
|
+
export function isCanonRuntimeControlToolCall(params) {
|
|
295
|
+
return normalizeToolName(params.tool) === CANON_RUNTIME_CONTROL_TOOL_NAME;
|
|
296
|
+
}
|
|
297
|
+
export function isCodexServiceAgentToolCall(params) {
|
|
298
|
+
const toolName = normalizeToolName(params.tool);
|
|
299
|
+
return toolName === CANON_RUNTIME_CONTROL_TOOL_NAME || toolName === CODEX_NO_REPLY_TOOL_NAME;
|
|
300
|
+
}
|
|
301
|
+
export function parseCanonRuntimeControlRequest(params) {
|
|
302
|
+
if (!isCanonRuntimeControlToolCall(params))
|
|
303
|
+
return null;
|
|
304
|
+
const args = parseToolArguments(params.arguments);
|
|
305
|
+
const allowedKeys = new Set(['action', 'card', 'prompt', 'placeholder']);
|
|
306
|
+
if (Object.keys(args).some((key) => !allowedKeys.has(key)))
|
|
307
|
+
return null;
|
|
308
|
+
const action = readString(args, 'action');
|
|
309
|
+
if (action !== 'send_card' && action !== 'request_card' && action !== 'request_input') {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
action,
|
|
314
|
+
...(args.card !== undefined ? { card: args.card } : {}),
|
|
315
|
+
...(readString(args, 'prompt') ? { prompt: readString(args, 'prompt') } : {}),
|
|
316
|
+
...(readString(args, 'placeholder') ? { placeholder: readString(args, 'placeholder') } : {}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
253
319
|
/** Private rationale, when the model supplied one. Logged, never rendered. */
|
|
254
320
|
export function readCodexNoReplyReason(params) {
|
|
255
321
|
const args = parseToolArguments(params.arguments);
|
|
@@ -262,6 +328,24 @@ export function readCodexNoReplyReason(params) {
|
|
|
262
328
|
export function codexNoReplyToolResult() {
|
|
263
329
|
return toolResult(true, { status: 'acknowledged', note: NO_REPLY_ACK_NOTE });
|
|
264
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Answer a `no_reply` dynamic-tool call end to end: mark the turn silent
|
|
333
|
+
* FIRST, then report the outcome to the server (fire-and-forget telemetry,
|
|
334
|
+
* `reportNoReplyOutcome` swallows every failure), then ack the model. The
|
|
335
|
+
* report can never change the turn: a rejecting or unreachable server leaves
|
|
336
|
+
* the flag and the ack exactly as they were. `sourceMessageId` is the
|
|
337
|
+
* binding's own record of the message that started this turn — never
|
|
338
|
+
* model-supplied.
|
|
339
|
+
*/
|
|
340
|
+
export function answerCodexNoReply(input) {
|
|
341
|
+
input.markSilenced();
|
|
342
|
+
reportNoReplyOutcome(input.client, {
|
|
343
|
+
conversationId: input.conversationId,
|
|
344
|
+
...(input.sourceMessageId ? { messageId: input.sourceMessageId } : {}),
|
|
345
|
+
reason: readCodexNoReplyReason(input.params),
|
|
346
|
+
});
|
|
347
|
+
return codexNoReplyToolResult();
|
|
348
|
+
}
|
|
265
349
|
/**
|
|
266
350
|
* The whole `codex_app` admission decision, as data.
|
|
267
351
|
*
|
|
@@ -296,6 +380,12 @@ export async function handleCodexAppToolCall(runtime, params) {
|
|
|
296
380
|
error: 'no_reply is answered by the Canon host, not the app-tool bridge.',
|
|
297
381
|
});
|
|
298
382
|
}
|
|
383
|
+
if (toolName === CANON_RUNTIME_CONTROL_TOOL_NAME) {
|
|
384
|
+
return toolResult(false, {
|
|
385
|
+
tool: toolName,
|
|
386
|
+
error: 'canon_runtime_control is answered by the Canon host, not the app-tool bridge.',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
299
389
|
const unsupportedReason = UNSUPPORTED_TOOLS.get(toolName);
|
|
300
390
|
if (unsupportedReason) {
|
|
301
391
|
return toolResult(false, { tool: toolName, error: unsupportedReason });
|
|
@@ -348,7 +438,7 @@ async function createThread(runtime, args) {
|
|
|
348
438
|
const started = await runtime.adapter.requestAppServer('thread/start', {
|
|
349
439
|
cwd,
|
|
350
440
|
...(readString(args, 'model') ?? runtime.model ? { model: readString(args, 'model') ?? runtime.model } : {}),
|
|
351
|
-
dynamicTools:
|
|
441
|
+
dynamicTools: CODEX_DETACHED_THREAD_DYNAMIC_TOOLS,
|
|
352
442
|
experimentalRawEvents: false,
|
|
353
443
|
persistExtendedHistory: true,
|
|
354
444
|
});
|
package/dist/host.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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
|
+
import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type PreparedExecutionEnvironment, type WorkspaceOption, type CanonWorkspaceRootMetadata, type RuntimeStreamingPayload, type TurnLifecycleState, type TurnOutputBlock, type TurnVerbosity, type TurnVerbosityConfig } from '@canonmsg/core';
|
|
4
|
+
import { type CodexSandboxMode } from './adapter.js';
|
|
4
5
|
import { type CodexSkillMetadata } from './app-server-adapter.js';
|
|
6
|
+
import { deriveCodexPermissionEnvelope } from './permission-mode.js';
|
|
5
7
|
import { type CodexControlOption } from './model-catalog.js';
|
|
6
8
|
interface HostSessionState {
|
|
7
9
|
lastError?: string;
|
|
@@ -68,6 +70,31 @@ export declare function getCodexRequestingUserId(message: {
|
|
|
68
70
|
senderId: string;
|
|
69
71
|
senderType?: 'human' | 'ai_agent';
|
|
70
72
|
}): string | null;
|
|
73
|
+
export declare function resolveSessionExecutionMode(config: {
|
|
74
|
+
executionMode?: ExecutionEnvironmentMode;
|
|
75
|
+
} | null | undefined, serviceAgentMode?: boolean): ExecutionEnvironmentMode;
|
|
76
|
+
export declare function resolveWorkspaceCwd(config: {
|
|
77
|
+
workspaceId?: string;
|
|
78
|
+
retiredWorkspaceConfig?: boolean;
|
|
79
|
+
} | null, serviceAgentMode?: boolean): string;
|
|
80
|
+
interface CodexEffectiveRuntimePolicy {
|
|
81
|
+
model?: string;
|
|
82
|
+
permissionMode?: string;
|
|
83
|
+
sandbox: CodexSandboxMode | null;
|
|
84
|
+
fullAuto: boolean;
|
|
85
|
+
bypassApprovalsAndSandbox: boolean;
|
|
86
|
+
fingerprint: string;
|
|
87
|
+
}
|
|
88
|
+
export declare function resolveCodexEffectiveRuntimePolicy(input: {
|
|
89
|
+
args: Record<string, unknown>;
|
|
90
|
+
config: {
|
|
91
|
+
model?: string;
|
|
92
|
+
permissionMode?: string;
|
|
93
|
+
} | null | undefined;
|
|
94
|
+
permissionEnvelope: ReturnType<typeof deriveCodexPermissionEnvelope>;
|
|
95
|
+
environment: Pick<PreparedExecutionEnvironment, 'baseCwd' | 'mode'>;
|
|
96
|
+
serviceAgentMode?: boolean;
|
|
97
|
+
}): CodexEffectiveRuntimePolicy;
|
|
71
98
|
/**
|
|
72
99
|
* `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
|
|
73
100
|
* per-conversation-type default".
|
|
@@ -137,6 +164,7 @@ export declare function planCodexStreamingWrite(input: {
|
|
|
137
164
|
* same decision at its `text_delta` handler.
|
|
138
165
|
*/
|
|
139
166
|
export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
|
|
167
|
+
export declare function selectCodexNativeImagePaths(imagePaths: readonly string[], nativeVisionEnabled: boolean): string[];
|
|
140
168
|
/**
|
|
141
169
|
* Whether this turn may post the media it generated in the workspace.
|
|
142
170
|
*
|
package/dist/host.js
CHANGED
|
@@ -6,10 +6,11 @@ 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, 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,
|
|
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, 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
|
+
import { validateCard } from '@canonmsg/rich-cards';
|
|
10
11
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
11
12
|
import { CodexAppServerAdapter, } from './app-server-adapter.js';
|
|
12
|
-
import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME,
|
|
13
|
+
import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME, CODEX_SERVICE_AGENT_DYNAMIC_TOOLS, answerCodexNoReply, classifyCodexAppToolRequest, deniedCodexAppToolResult, handleCodexAppToolCall, isCanonRuntimeControlToolCall, isCodexAppToolCall, isCodexServiceAgentToolCall, parseCanonRuntimeControlRequest, readCodexNoReplyReason, successfulCodexAppToolResult, } from './codex-app-tools.js';
|
|
13
14
|
import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
|
|
14
15
|
import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
|
|
15
16
|
import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
|
|
@@ -42,6 +43,8 @@ COMMON FLAGS
|
|
|
42
43
|
How much of a turn's middle readers see.
|
|
43
44
|
Default (auto): verbose in direct chats,
|
|
44
45
|
quiet in groups. Env: CANON_TURN_VERBOSITY
|
|
46
|
+
--service-agent Expose Canon conversation tools, not coding tools
|
|
47
|
+
--no-native-vision Keep attachment paths but omit native image input
|
|
45
48
|
--help, -h Show this help
|
|
46
49
|
--version, -V Show package version
|
|
47
50
|
|
|
@@ -259,15 +262,17 @@ async function loadSessionConfig(conversationId, agentId, rtdb) {
|
|
|
259
262
|
extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
|
|
260
263
|
});
|
|
261
264
|
}
|
|
262
|
-
function resolveSessionExecutionMode(config) {
|
|
265
|
+
export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
|
|
266
|
+
if (serviceAgentMode)
|
|
267
|
+
return 'locked';
|
|
263
268
|
if (config?.executionMode)
|
|
264
269
|
return config.executionMode;
|
|
265
270
|
throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
|
|
266
271
|
}
|
|
267
|
-
function resolveWorkspaceCwd(config) {
|
|
272
|
+
export function resolveWorkspaceCwd(config, serviceAgentMode = false) {
|
|
268
273
|
return resolveHostWorkspaceCwd({
|
|
269
274
|
workspaceOptions,
|
|
270
|
-
config,
|
|
275
|
+
config: serviceAgentMode ? null : config,
|
|
271
276
|
defaultCwd: workingDir,
|
|
272
277
|
});
|
|
273
278
|
}
|
|
@@ -286,9 +291,11 @@ function stringArg(args, key) {
|
|
|
286
291
|
function boolArg(args, key) {
|
|
287
292
|
return args[key] === true;
|
|
288
293
|
}
|
|
289
|
-
function resolveCodexEffectiveRuntimePolicy(input) {
|
|
294
|
+
export function resolveCodexEffectiveRuntimePolicy(input) {
|
|
290
295
|
const model = input.config?.model ?? stringArg(input.args, 'model');
|
|
291
|
-
const permissionMode = input.
|
|
296
|
+
const permissionMode = input.serviceAgentMode
|
|
297
|
+
? input.permissionEnvelope.defaultPermissionMode
|
|
298
|
+
: input.config?.permissionMode ?? input.permissionEnvelope.defaultPermissionMode;
|
|
292
299
|
if (permissionMode
|
|
293
300
|
&& !input.permissionEnvelope.availablePermissionModes.some((option) => option.value === permissionMode)) {
|
|
294
301
|
throw new ExecutionEnvironmentError(`Permission mode "${permissionMode}" is not supported by this Codex host.`, 'This Canon host was started with stricter approval settings. Choose one of the advertised permission modes or restart the host with more permissive flags.');
|
|
@@ -538,6 +545,9 @@ export function planCodexStreamingWrite(input) {
|
|
|
538
545
|
export function shouldStopTypingDotsOnStreamedText(turnVerbosity) {
|
|
539
546
|
return turnVerbosity !== 'quiet';
|
|
540
547
|
}
|
|
548
|
+
export function selectCodexNativeImagePaths(imagePaths, nativeVisionEnabled) {
|
|
549
|
+
return nativeVisionEnabled ? [...imagePaths] : [];
|
|
550
|
+
}
|
|
541
551
|
/**
|
|
542
552
|
* Whether this turn may post the media it generated in the workspace.
|
|
543
553
|
*
|
|
@@ -585,6 +595,8 @@ export async function main() {
|
|
|
585
595
|
'show-runtime-detail': { type: 'string', multiple: true },
|
|
586
596
|
'hide-runtime-detail': { type: 'string', multiple: true },
|
|
587
597
|
'turn-verbosity': { type: 'string' },
|
|
598
|
+
'service-agent': { type: 'boolean' },
|
|
599
|
+
'no-native-vision': { type: 'boolean' },
|
|
588
600
|
'full-auto': { type: 'boolean' },
|
|
589
601
|
'dangerously-bypass-approvals-and-sandbox': { type: 'boolean' },
|
|
590
602
|
},
|
|
@@ -600,6 +612,11 @@ export async function main() {
|
|
|
600
612
|
env: process.env.CANON_TURN_VERBOSITY,
|
|
601
613
|
onWarning: (message) => console.error(`[canon-codex] ${message}`),
|
|
602
614
|
});
|
|
615
|
+
const serviceAgentMode = args['service-agent'] === true;
|
|
616
|
+
const nativeVisionEnabled = args['no-native-vision'] !== true;
|
|
617
|
+
const codexDynamicTools = serviceAgentMode
|
|
618
|
+
? CODEX_SERVICE_AGENT_DYNAMIC_TOOLS
|
|
619
|
+
: CODEX_APP_DYNAMIC_TOOLS;
|
|
603
620
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
604
621
|
const workspaceDiscovery = buildConfiguredWorkspaceOptionsWithRoots({
|
|
605
622
|
primaryCwd: workingDir,
|
|
@@ -1116,6 +1133,8 @@ export async function main() {
|
|
|
1116
1133
|
if (!session)
|
|
1117
1134
|
return;
|
|
1118
1135
|
session.closed = true;
|
|
1136
|
+
session.currentTurnAbortController?.abort(new Error('Codex session closed'));
|
|
1137
|
+
session.currentTurnAbortController = null;
|
|
1119
1138
|
stopVisibleWorkSignal(session);
|
|
1120
1139
|
if ('close' in session.adapter && typeof session.adapter.close === 'function') {
|
|
1121
1140
|
session.adapter.close();
|
|
@@ -1139,6 +1158,7 @@ export async function main() {
|
|
|
1139
1158
|
session.activeSelfContextId = null;
|
|
1140
1159
|
session.state.lastError = undefined;
|
|
1141
1160
|
if (session.running) {
|
|
1161
|
+
session.currentTurnAbortController?.abort(new Error('Codex session reset'));
|
|
1142
1162
|
await session.adapter.interrupt();
|
|
1143
1163
|
session.turnState = 'interrupted';
|
|
1144
1164
|
}
|
|
@@ -1186,8 +1206,8 @@ export async function main() {
|
|
|
1186
1206
|
}
|
|
1187
1207
|
const creation = (async () => {
|
|
1188
1208
|
const config = await loadSessionConfig(conversationId, agentId, rtdb);
|
|
1189
|
-
const sessionExecutionMode = resolveSessionExecutionMode(config);
|
|
1190
|
-
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
1209
|
+
const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode);
|
|
1210
|
+
const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
|
|
1191
1211
|
const environment = prepareConversationEnvironment({
|
|
1192
1212
|
agentId,
|
|
1193
1213
|
conversationId,
|
|
@@ -1201,6 +1221,7 @@ export async function main() {
|
|
|
1201
1221
|
config,
|
|
1202
1222
|
permissionEnvelope: codexPermissionEnvelope,
|
|
1203
1223
|
environment,
|
|
1224
|
+
serviceAgentMode,
|
|
1204
1225
|
});
|
|
1205
1226
|
const modelGuard = buildCodexModelGuardMessage(policy.model, codexCliStatus);
|
|
1206
1227
|
if (modelGuard) {
|
|
@@ -1226,7 +1247,7 @@ export async function main() {
|
|
|
1226
1247
|
configOverrides: args.config ?? [],
|
|
1227
1248
|
fullAuto: policy.fullAuto,
|
|
1228
1249
|
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
1229
|
-
dynamicTools:
|
|
1250
|
+
dynamicTools: codexDynamicTools,
|
|
1230
1251
|
})
|
|
1231
1252
|
: new CodexConversationAdapter({
|
|
1232
1253
|
cwd: sessionCwd,
|
|
@@ -1259,6 +1280,7 @@ export async function main() {
|
|
|
1259
1280
|
currentTurnOpenedAt: null,
|
|
1260
1281
|
currentTurnUpdatedAt: null,
|
|
1261
1282
|
currentTurnCanUseCodexAppTools: false,
|
|
1283
|
+
currentTurnAbortController: null,
|
|
1262
1284
|
// Corrected by the first turn that runs; a session with no turn
|
|
1263
1285
|
// publishes nothing anyway, and quiet is never an accident.
|
|
1264
1286
|
turnVerbosity: 'verbose',
|
|
@@ -1333,7 +1355,10 @@ export async function main() {
|
|
|
1333
1355
|
// No `turnVerbosity`: this prompt continues the same conversation, so it
|
|
1334
1356
|
// keeps whatever the session last resolved rather than reverting to the
|
|
1335
1357
|
// default.
|
|
1336
|
-
enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', {
|
|
1358
|
+
enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', {
|
|
1359
|
+
canUseCodexAppTools: serviceAgentMode || responseUserId === ownerId,
|
|
1360
|
+
requestingUserId: responseUserId,
|
|
1361
|
+
});
|
|
1337
1362
|
}
|
|
1338
1363
|
function resolveArtifactRoutingMode(participantContext) {
|
|
1339
1364
|
return participantContext.conversationType === 'direct' && participantContext.isOwner
|
|
@@ -1349,7 +1374,7 @@ export async function main() {
|
|
|
1349
1374
|
function resolveCodexTurnModes(participantContext, message) {
|
|
1350
1375
|
return {
|
|
1351
1376
|
artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
|
|
1352
|
-
canUseCodexAppTools: participantContext.isOwner,
|
|
1377
|
+
canUseCodexAppTools: participantContext.isOwner || serviceAgentMode,
|
|
1353
1378
|
turnVerbosity: resolveTurnVerbosity({
|
|
1354
1379
|
configured: configuredTurnVerbosity,
|
|
1355
1380
|
conversationType: participantContext.conversationType,
|
|
@@ -1366,11 +1391,14 @@ export async function main() {
|
|
|
1366
1391
|
const args = isRecord(params.arguments) ? params.arguments : null;
|
|
1367
1392
|
return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
|
|
1368
1393
|
}
|
|
1369
|
-
async function handleCodexServerRequest(session, request, requestingUserId) {
|
|
1394
|
+
async function handleCodexServerRequest(session, request, requestingUserId, sourceMessageId) {
|
|
1370
1395
|
const requestId = String(request.id);
|
|
1371
1396
|
const params = request.params;
|
|
1372
1397
|
const expiresAt = Date.now() + 30 * 60_000;
|
|
1373
1398
|
if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
|
|
1399
|
+
if (serviceAgentMode && !isCodexServiceAgentToolCall(params)) {
|
|
1400
|
+
return deniedCodexAppToolResult('This service agent exposes only Canon conversation tools.');
|
|
1401
|
+
}
|
|
1374
1402
|
// The admission order — no_reply above the owner gate — is pinned by
|
|
1375
1403
|
// `classifyCodexAppToolRequest`'s tests, not by the shape of this block.
|
|
1376
1404
|
const disposition = classifyCodexAppToolRequest({
|
|
@@ -1379,14 +1407,116 @@ export async function main() {
|
|
|
1379
1407
|
canUseAppTools: session.currentTurnCanUseCodexAppTools,
|
|
1380
1408
|
});
|
|
1381
1409
|
if (disposition === 'no-reply') {
|
|
1382
|
-
|
|
1410
|
+
const result = answerCodexNoReply({
|
|
1411
|
+
client,
|
|
1412
|
+
conversationId: session.conversationId,
|
|
1413
|
+
sourceMessageId,
|
|
1414
|
+
params,
|
|
1415
|
+
markSilenced: () => { session.currentTurnSilenced = true; },
|
|
1416
|
+
});
|
|
1383
1417
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn chose no_reply`
|
|
1384
1418
|
+ ` (reason: ${readCodexNoReplyReason(params) ? 'given' : 'none'})`);
|
|
1385
|
-
return
|
|
1419
|
+
return result;
|
|
1386
1420
|
}
|
|
1387
1421
|
if (disposition === 'denied-non-owner') {
|
|
1388
1422
|
return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
|
|
1389
1423
|
}
|
|
1424
|
+
if (isCanonRuntimeControlToolCall(params)) {
|
|
1425
|
+
const command = parseCanonRuntimeControlRequest(params);
|
|
1426
|
+
if (!command) {
|
|
1427
|
+
return deniedCodexAppToolResult('Invalid canon_runtime_control arguments.');
|
|
1428
|
+
}
|
|
1429
|
+
if (command.action === 'request_input') {
|
|
1430
|
+
if (!command.prompt) {
|
|
1431
|
+
return deniedCodexAppToolResult('request_input requires prompt.');
|
|
1432
|
+
}
|
|
1433
|
+
const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
|
|
1434
|
+
const inputId = `codex_input_${randomUUID()}`;
|
|
1435
|
+
const question = command.placeholder
|
|
1436
|
+
? `${command.prompt}\n\nFormat hint: ${command.placeholder}`
|
|
1437
|
+
: command.prompt;
|
|
1438
|
+
const response = await runtimeRequests.request('input', session.conversationId, {
|
|
1439
|
+
kind: 'clarify',
|
|
1440
|
+
title: 'Input requested',
|
|
1441
|
+
prompt: command.prompt,
|
|
1442
|
+
questions: [{ id: 'response', header: 'Response', question, allowOther: true }],
|
|
1443
|
+
...(responseRouting.responseUserId
|
|
1444
|
+
? { responseUserId: responseRouting.responseUserId }
|
|
1445
|
+
: {}),
|
|
1446
|
+
turnId: session.currentTurnId ?? undefined,
|
|
1447
|
+
}, {
|
|
1448
|
+
requestId: inputId,
|
|
1449
|
+
expiresAt,
|
|
1450
|
+
signal: session.currentTurnAbortController?.signal,
|
|
1451
|
+
onCreated: () => {
|
|
1452
|
+
session.turnState = 'waiting_input';
|
|
1453
|
+
markTurnProgress(session);
|
|
1454
|
+
stopVisibleWorkSignal(session);
|
|
1455
|
+
writeTurn(session);
|
|
1456
|
+
writeCodexStreaming(session, null, 'waiting_input');
|
|
1457
|
+
},
|
|
1458
|
+
});
|
|
1459
|
+
resumeTurnFromWaiting(session);
|
|
1460
|
+
return successfulCodexAppToolResult(response);
|
|
1461
|
+
}
|
|
1462
|
+
const validation = validateCard(command.card);
|
|
1463
|
+
if (!validation.ok || !validation.card) {
|
|
1464
|
+
return deniedCodexAppToolResult(`Invalid canon.card.v1 card: ${validation.errors.join('; ')}`);
|
|
1465
|
+
}
|
|
1466
|
+
const card = validation.card;
|
|
1467
|
+
const interactive = card.blocks.some((block) => block.kind === 'actions');
|
|
1468
|
+
if (command.action === 'send_card') {
|
|
1469
|
+
if (interactive) {
|
|
1470
|
+
return deniedCodexAppToolResult('send_card cannot contain actions; use request_card.');
|
|
1471
|
+
}
|
|
1472
|
+
const cardId = card.cardId ?? `codex_card_${randomUUID()}`;
|
|
1473
|
+
const created = await client.createRuntimeCardRequest({
|
|
1474
|
+
conversationId: session.conversationId,
|
|
1475
|
+
card: { ...card, cardId },
|
|
1476
|
+
cardId,
|
|
1477
|
+
expiresAt,
|
|
1478
|
+
turnId: session.currentTurnId ?? undefined,
|
|
1479
|
+
});
|
|
1480
|
+
return successfulCodexAppToolResult({
|
|
1481
|
+
status: 'sent',
|
|
1482
|
+
cardId: created.cardId,
|
|
1483
|
+
messageId: created.messageId,
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
if (!interactive) {
|
|
1487
|
+
return deniedCodexAppToolResult('request_card requires an actions block; use send_card.');
|
|
1488
|
+
}
|
|
1489
|
+
const cardId = card.cardId ?? `codex_card_${randomUUID()}`;
|
|
1490
|
+
const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
|
|
1491
|
+
const response = await runtimeRequests.request('card', session.conversationId, {
|
|
1492
|
+
card,
|
|
1493
|
+
turnId: session.currentTurnId ?? undefined,
|
|
1494
|
+
...(responseRouting.responseUserId
|
|
1495
|
+
? { responseUserId: responseRouting.responseUserId }
|
|
1496
|
+
: {}),
|
|
1497
|
+
}, {
|
|
1498
|
+
requestId: cardId,
|
|
1499
|
+
expiresAt,
|
|
1500
|
+
signal: session.currentTurnAbortController?.signal,
|
|
1501
|
+
onCreated: () => {
|
|
1502
|
+
session.turnState = 'waiting_input';
|
|
1503
|
+
markTurnProgress(session);
|
|
1504
|
+
upsertTurnBlock(session, {
|
|
1505
|
+
id: `card:${cardId}`,
|
|
1506
|
+
kind: 'input',
|
|
1507
|
+
status: 'pending',
|
|
1508
|
+
title: card.title,
|
|
1509
|
+
summary: card.template ?? 'runtime card',
|
|
1510
|
+
});
|
|
1511
|
+
writeTurn(session);
|
|
1512
|
+
stopVisibleWorkSignal(session);
|
|
1513
|
+
writeCodexStreaming(session, null, 'waiting_input');
|
|
1514
|
+
},
|
|
1515
|
+
});
|
|
1516
|
+
completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
|
|
1517
|
+
resumeTurnFromWaiting(session);
|
|
1518
|
+
return successfulCodexAppToolResult(response);
|
|
1519
|
+
}
|
|
1390
1520
|
// `disposition` already ruled on the transport (it is checked first, so a
|
|
1391
1521
|
// wrong transport never reaches the branches above). This repeats the
|
|
1392
1522
|
// test only to narrow the adapter type for the bridge call.
|
|
@@ -1404,9 +1534,13 @@ export async function main() {
|
|
|
1404
1534
|
}
|
|
1405
1535
|
const runtimeCardPayload = runtimeCardRequestPayload(request.method, params);
|
|
1406
1536
|
if (runtimeCardPayload) {
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
|
|
1537
|
+
const validation = validateCard(runtimeCardPayload);
|
|
1538
|
+
const card = validation.card;
|
|
1539
|
+
if (!validation.ok || !card) {
|
|
1540
|
+
return {
|
|
1541
|
+
status: 'cancelled',
|
|
1542
|
+
error: `Invalid canon.card.v1 card: ${validation.errors.join('; ')}`,
|
|
1543
|
+
};
|
|
1410
1544
|
}
|
|
1411
1545
|
const cardId = readString(params, 'cardId')
|
|
1412
1546
|
?? readString(params, 'itemId')
|
|
@@ -1437,6 +1571,7 @@ export async function main() {
|
|
|
1437
1571
|
}, {
|
|
1438
1572
|
requestId: cardId,
|
|
1439
1573
|
expiresAt,
|
|
1574
|
+
signal: session.currentTurnAbortController?.signal,
|
|
1440
1575
|
onCreated: () => {
|
|
1441
1576
|
requestCreated = true;
|
|
1442
1577
|
session.turnState = 'waiting_input';
|
|
@@ -1458,8 +1593,14 @@ export async function main() {
|
|
|
1458
1593
|
status: 'submitted',
|
|
1459
1594
|
...(cardResult.actionId ? { actionId: cardResult.actionId } : {}),
|
|
1460
1595
|
...(cardResult.values ? { values: cardResult.values } : {}),
|
|
1596
|
+
...(cardResult.respondedBy ? { respondedBy: cardResult.respondedBy } : {}),
|
|
1461
1597
|
}
|
|
1462
|
-
: {
|
|
1598
|
+
: {
|
|
1599
|
+
status: cardResult.status,
|
|
1600
|
+
...('respondedBy' in cardResult && cardResult.respondedBy
|
|
1601
|
+
? { respondedBy: cardResult.respondedBy }
|
|
1602
|
+
: {}),
|
|
1603
|
+
};
|
|
1463
1604
|
requestResolved = true;
|
|
1464
1605
|
const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
|
|
1465
1606
|
await sendMessageWithRetry(client, session.conversationId, outcome.text, {
|
|
@@ -1529,7 +1670,11 @@ export async function main() {
|
|
|
1529
1670
|
},
|
|
1530
1671
|
},
|
|
1531
1672
|
turnId: session.currentTurnId ?? undefined,
|
|
1532
|
-
}, {
|
|
1673
|
+
}, {
|
|
1674
|
+
requestId: inputId,
|
|
1675
|
+
expiresAt,
|
|
1676
|
+
signal: session.currentTurnAbortController?.signal,
|
|
1677
|
+
});
|
|
1533
1678
|
resumeTurnFromWaiting(session);
|
|
1534
1679
|
return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
|
|
1535
1680
|
}
|
|
@@ -1548,7 +1693,11 @@ export async function main() {
|
|
|
1548
1693
|
? { responseUserId: responseRouting.responseUserId }
|
|
1549
1694
|
: {}),
|
|
1550
1695
|
allowSessionRule: responseRouting.allowSessionRule,
|
|
1551
|
-
}, {
|
|
1696
|
+
}, {
|
|
1697
|
+
requestId: approvalId,
|
|
1698
|
+
expiresAt,
|
|
1699
|
+
signal: session.currentTurnAbortController?.signal,
|
|
1700
|
+
});
|
|
1552
1701
|
resumeTurnFromWaiting(session);
|
|
1553
1702
|
if (request.method === 'item/permissions/requestApproval') {
|
|
1554
1703
|
return response.decision === 'allow'
|
|
@@ -1589,7 +1738,7 @@ export async function main() {
|
|
|
1589
1738
|
? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
|
|
1590
1739
|
: `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
|
|
1591
1740
|
enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', {
|
|
1592
|
-
canUseCodexAppTools: input.isOwner,
|
|
1741
|
+
canUseCodexAppTools: input.isOwner || serviceAgentMode,
|
|
1593
1742
|
requestingUserId: getCodexRequestingUserId(input.message),
|
|
1594
1743
|
});
|
|
1595
1744
|
return;
|
|
@@ -1636,9 +1785,10 @@ export async function main() {
|
|
|
1636
1785
|
});
|
|
1637
1786
|
const replyContext = replyMedia.replyContext;
|
|
1638
1787
|
const promptMaterialized = [...replyMedia.materialized, ...materialized];
|
|
1639
|
-
const
|
|
1788
|
+
const discoveredImagePaths = promptMaterialized
|
|
1640
1789
|
.map((attachment) => getCodexImagePath(attachment))
|
|
1641
1790
|
.filter((path) => path !== null);
|
|
1791
|
+
const imagePaths = selectCodexNativeImagePaths(discoveredImagePaths, nativeVisionEnabled);
|
|
1642
1792
|
const mediaAddDirs = uniqueStrings(promptMaterialized.map((attachment) => dirname(attachment.path)));
|
|
1643
1793
|
const participantContext = hydrated.participantContext;
|
|
1644
1794
|
const autoReply = input.turnDispatch?.kind === 'run_turn'
|
|
@@ -1692,6 +1842,7 @@ export async function main() {
|
|
|
1692
1842
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
1693
1843
|
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
|
|
1694
1844
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
1845
|
+
session.currentTurnAbortController?.abort(new Error('Codex turn interrupted by a newer message'));
|
|
1695
1846
|
await session.adapter.interrupt().catch(() => { });
|
|
1696
1847
|
clearStreaming(input.conversationId);
|
|
1697
1848
|
typingSignals.clear(input.conversationId).catch(() => { });
|
|
@@ -1728,6 +1879,7 @@ export async function main() {
|
|
|
1728
1879
|
session.currentTurnOpenedAt = Date.now();
|
|
1729
1880
|
session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
|
|
1730
1881
|
session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
|
|
1882
|
+
session.currentTurnAbortController = new AbortController();
|
|
1731
1883
|
// A continuation prompt (a plan-review result) carries none, and keeps the
|
|
1732
1884
|
// conversation's last answer rather than silently reverting to verbose.
|
|
1733
1885
|
session.turnVerbosity = nextTurn.turnVerbosity ?? session.turnVerbosity;
|
|
@@ -1924,7 +2076,7 @@ export async function main() {
|
|
|
1924
2076
|
};
|
|
1925
2077
|
const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
|
|
1926
2078
|
planMode: nextTurn.planMode,
|
|
1927
|
-
onServerRequest: (request) => handleCodexServerRequest(session, request, nextTurn.requestingUserId ?? null),
|
|
2079
|
+
onServerRequest: (request) => handleCodexServerRequest(session, request, nextTurn.requestingUserId ?? null, nextTurn.sourceMessageId ?? null),
|
|
1928
2080
|
});
|
|
1929
2081
|
let result = await runTurnOnce();
|
|
1930
2082
|
if (!result.interrupted
|
|
@@ -2129,6 +2281,8 @@ export async function main() {
|
|
|
2129
2281
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
2130
2282
|
}
|
|
2131
2283
|
finally {
|
|
2284
|
+
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2285
|
+
session.currentTurnAbortController = null;
|
|
2132
2286
|
persistInboundRecoveryCursor(session.conversationId, nextTurn.sourceMessageId);
|
|
2133
2287
|
if (session.pendingDroppedRecoveryCursor) {
|
|
2134
2288
|
persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
|
|
@@ -2178,10 +2332,10 @@ export async function main() {
|
|
|
2178
2332
|
}
|
|
2179
2333
|
}
|
|
2180
2334
|
let streamConnected = false;
|
|
2181
|
-
const hostAvailableExecutionModes =
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
const codexPermissionEnvelope = deriveCodexPermissionEnvelope(args);
|
|
2335
|
+
const hostAvailableExecutionModes = serviceAgentMode
|
|
2336
|
+
? ['locked']
|
|
2337
|
+
: [...EXECUTION_ENVIRONMENT_MODES];
|
|
2338
|
+
const codexPermissionEnvelope = deriveCodexPermissionEnvelope(serviceAgentMode ? { sandbox: 'read-only' } : args);
|
|
2185
2339
|
const configuredCodexEffort = readCodexConfiguredEffort(args.config ?? []);
|
|
2186
2340
|
let codexModels = [];
|
|
2187
2341
|
let codexModelOptions = buildCodexModelOptions(codexModels, args.model);
|
|
@@ -2339,6 +2493,7 @@ export async function main() {
|
|
|
2339
2493
|
rememberDroppedRecoveryCursor(session, droppedPrompts);
|
|
2340
2494
|
}
|
|
2341
2495
|
if (session.running) {
|
|
2496
|
+
session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
|
|
2342
2497
|
await session.adapter.interrupt();
|
|
2343
2498
|
}
|
|
2344
2499
|
session.turnState = 'interrupted';
|
|
@@ -2660,6 +2815,9 @@ export async function main() {
|
|
|
2660
2815
|
controlPoller.stop();
|
|
2661
2816
|
clearInterval(heartbeat);
|
|
2662
2817
|
clearInterval(idleCheck);
|
|
2818
|
+
for (const session of sessions.values()) {
|
|
2819
|
+
session.currentTurnAbortController?.abort(new Error('Codex host shutting down'));
|
|
2820
|
+
}
|
|
2663
2821
|
runtimeRequests.dispose();
|
|
2664
2822
|
stream.stop();
|
|
2665
2823
|
await runtimeState.clearAgentRuntime().catch(() => { });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"scripts"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../coding-agent-host",
|
|
24
|
+
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../coding-agent-host ../rich-cards",
|
|
25
25
|
"build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
26
26
|
"dev": "npm run prepare:workspace-deps && tsc --watch",
|
|
27
27
|
"smoke": "node scripts/smoke-test.mjs",
|
|
@@ -29,9 +29,10 @@
|
|
|
29
29
|
"prepack": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@canonmsg/agent-sdk": "^8.
|
|
32
|
+
"@canonmsg/agent-sdk": "^8.3.0",
|
|
33
33
|
"@canonmsg/coding-agent-host": "^0.5.0",
|
|
34
|
-
"@canonmsg/core": "^10.1
|
|
34
|
+
"@canonmsg/core": "^10.3.1",
|
|
35
|
+
"@canonmsg/rich-cards": "^0.10.0"
|
|
35
36
|
},
|
|
36
37
|
"engines": {
|
|
37
38
|
"node": ">=18.0.0"
|