@canonmsg/codex-plugin 0.26.1 → 0.27.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 +21 -0
- package/dist/app-server-adapter.js +7 -1
- package/dist/codex-app-tools.d.ts +26 -1
- package/dist/codex-app-tools.js +85 -2
- package/dist/host.d.ts +31 -1
- package/dist/host.js +340 -131
- package/dist/register.js +1 -0
- package/dist/session-store.d.ts +2 -0
- package/dist/session-store.js +8 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -79,6 +79,10 @@ model cannot select another user, conversation, or responder. It can display a
|
|
|
79
79
|
card with `send_card`, wait for an action card with `request_card`, or ask one
|
|
80
80
|
standalone question with `request_input`.
|
|
81
81
|
|
|
82
|
+
Service-agent sessions use the configured shared workspace and read-only local
|
|
83
|
+
filesystem automatically. They do not ask conversation members to choose a
|
|
84
|
+
coding workspace, execution mode, model, reasoning level, or permission mode.
|
|
85
|
+
|
|
82
86
|
Authorized conversation members may use a service agent even when they do not
|
|
83
87
|
own its Canon identity. Normal coding-host sessions keep their existing
|
|
84
88
|
owner-only execution boundary.
|
|
@@ -105,6 +109,23 @@ discovery it also offers no model picker and a fixed effort list. It registers
|
|
|
105
109
|
no dynamic tools either, so `no_reply` is unavailable there, the same way rich
|
|
106
110
|
cards are.
|
|
107
111
|
|
|
112
|
+
On the app-server transport, an owner-started foreground turn also receives a
|
|
113
|
+
small Canon communication surface: `codex_app.canon_send_to`, contact/request
|
|
114
|
+
and conversation listing, and `codex_app.canon_cancel_contact_request` for the
|
|
115
|
+
agent's own pending DM requests. `canon_send_to` accepts one stable contact
|
|
116
|
+
target and visible text; the host supplies trusted source-turn routing. These
|
|
117
|
+
tools are withheld from non-owner turns, service-agent mode, detached threads,
|
|
118
|
+
and the exec fallback. Contact cards only claim reach-out support when this
|
|
119
|
+
exact surface is mounted.
|
|
120
|
+
|
|
121
|
+
When a reach-out needs target-owner setup or approval, Canon parks the visible
|
|
122
|
+
opener. Terminal request outcomes enter a deduplicated local lifecycle inbox and
|
|
123
|
+
are supplied as trusted context on the next natural owner turn; they never start
|
|
124
|
+
a synthetic model turn. A connected outcome means Canon already delivered the
|
|
125
|
+
opener. After an SSE reconnect or expired replay window, the host refreshes
|
|
126
|
+
memberships, reconciles outbound request phases, and recovers unseen messages
|
|
127
|
+
from its persisted cursors before resuming live delivery.
|
|
128
|
+
|
|
108
129
|
Set `CANON_CODEX_TRANSPORT=exec` or `CANON_CODEX_TRANSPORT=app-server` to skip
|
|
109
130
|
the probe when it misfires.
|
|
110
131
|
|
|
@@ -638,7 +638,13 @@ function isForegroundCanonToolCall(params) {
|
|
|
638
638
|
const tool = rawTool.startsWith('codex_app.')
|
|
639
639
|
? rawTool.slice('codex_app.'.length)
|
|
640
640
|
: rawTool;
|
|
641
|
-
return tool === 'canon_runtime_control'
|
|
641
|
+
return tool === 'canon_runtime_control'
|
|
642
|
+
|| tool === 'canon_send_to'
|
|
643
|
+
|| tool === 'canon_list_contacts'
|
|
644
|
+
|| tool === 'canon_list_contact_requests'
|
|
645
|
+
|| tool === 'canon_list_conversations'
|
|
646
|
+
|| tool === 'canon_cancel_contact_request'
|
|
647
|
+
|| tool === 'no_reply';
|
|
642
648
|
}
|
|
643
649
|
function parseJson(line) {
|
|
644
650
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type NoReplyReportClient } from '@canonmsg/core';
|
|
1
|
+
import { type CanonClient, type CanonVerbName, type NoReplyReportClient } from '@canonmsg/core';
|
|
2
|
+
import { type OwnerBoundCanonContactTarget } from '@canonmsg/agent-tools';
|
|
2
3
|
type JsonRecord = Record<string, unknown>;
|
|
3
4
|
interface DynamicToolSpec {
|
|
4
5
|
[key: string]: unknown;
|
|
@@ -44,6 +45,11 @@ type DynamicToolCallResponse = {
|
|
|
44
45
|
*/
|
|
45
46
|
export declare const CODEX_NO_REPLY_TOOL_NAME = "no_reply";
|
|
46
47
|
export declare const CANON_RUNTIME_CONTROL_TOOL_NAME = "canon_runtime_control";
|
|
48
|
+
export declare const CODEX_CANON_SEND_TO_TOOL_NAME = "canon_send_to";
|
|
49
|
+
export declare const CODEX_CANON_LIST_CONTACTS_TOOL_NAME = "canon_list_contacts";
|
|
50
|
+
export declare const CODEX_CANON_LIST_CONTACT_REQUESTS_TOOL_NAME = "canon_list_contact_requests";
|
|
51
|
+
export declare const CODEX_CANON_LIST_CONVERSATIONS_TOOL_NAME = "canon_list_conversations";
|
|
52
|
+
export declare const CODEX_CANON_CANCEL_CONTACT_REQUEST_TOOL_NAME = "canon_cancel_contact_request";
|
|
47
53
|
/**
|
|
48
54
|
* What the model actually sees for the tool above, for prompt text that names
|
|
49
55
|
* it (the group posture cue). Only meaningful on the app-server transport —
|
|
@@ -65,7 +71,26 @@ export declare function successfulCodexAppToolResult(payload: unknown): DynamicT
|
|
|
65
71
|
/** True when this dynamic-tool call is the deliberate-silence verb. */
|
|
66
72
|
export declare function isCodexNoReplyToolCall(params: CodexAppToolCallParams): boolean;
|
|
67
73
|
export declare function isCanonRuntimeControlToolCall(params: CodexAppToolCallParams): boolean;
|
|
74
|
+
export declare function codexCanonVerbForToolCall(params: CodexAppToolCallParams): CanonVerbName | null;
|
|
75
|
+
export declare function isCodexCanonVerbToolCall(params: CodexAppToolCallParams): boolean;
|
|
68
76
|
export declare function isCodexServiceAgentToolCall(params: CodexAppToolCallParams): boolean;
|
|
77
|
+
export interface CodexCanonVerbTrustedContext {
|
|
78
|
+
conversationId: string;
|
|
79
|
+
sourceMessageId: string | null;
|
|
80
|
+
turnId: string | null;
|
|
81
|
+
replyContactTarget?: OwnerBoundCanonContactTarget;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Execute the deliberately filtered canonical-verb projection. The model can
|
|
85
|
+
* author only the destination and visible copy. Provenance and turn metadata
|
|
86
|
+
* come exclusively from the active host turn, so a prompt cannot forge its
|
|
87
|
+
* source or make an outbound message look like an unrelated event.
|
|
88
|
+
*/
|
|
89
|
+
export declare function answerCodexCanonVerb(input: {
|
|
90
|
+
client: CanonClient;
|
|
91
|
+
params: CodexAppToolCallParams;
|
|
92
|
+
context: CodexCanonVerbTrustedContext;
|
|
93
|
+
}): Promise<DynamicToolCallResponse>;
|
|
69
94
|
export interface CanonRuntimeControlRequest {
|
|
70
95
|
action: 'send_card' | 'request_card' | 'request_input';
|
|
71
96
|
card?: unknown;
|
package/dist/codex-app-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { NO_REPLY_ACK_NOTE, reportNoReplyOutcome } from '@canonmsg/core';
|
|
1
|
+
import { NO_REPLY_ACK_NOTE, reportNoReplyOutcome, } from '@canonmsg/core';
|
|
2
|
+
import { createOwnerBoundCanonCommunicationBinding, } from '@canonmsg/agent-tools';
|
|
2
3
|
const emptyObjectSchema = {
|
|
3
4
|
type: 'object',
|
|
4
5
|
properties: {},
|
|
@@ -90,6 +91,21 @@ function tool(name, description, inputSchema, deferLoading = true) {
|
|
|
90
91
|
*/
|
|
91
92
|
export const CODEX_NO_REPLY_TOOL_NAME = 'no_reply';
|
|
92
93
|
export const CANON_RUNTIME_CONTROL_TOOL_NAME = 'canon_runtime_control';
|
|
94
|
+
export const CODEX_CANON_SEND_TO_TOOL_NAME = 'canon_send_to';
|
|
95
|
+
export const CODEX_CANON_LIST_CONTACTS_TOOL_NAME = 'canon_list_contacts';
|
|
96
|
+
export const CODEX_CANON_LIST_CONTACT_REQUESTS_TOOL_NAME = 'canon_list_contact_requests';
|
|
97
|
+
export const CODEX_CANON_LIST_CONVERSATIONS_TOOL_NAME = 'canon_list_conversations';
|
|
98
|
+
export const CODEX_CANON_CANCEL_CONTACT_REQUEST_TOOL_NAME = 'canon_cancel_contact_request';
|
|
99
|
+
const CODEX_CANON_COMMUNICATION_BINDING = createOwnerBoundCanonCommunicationBinding({
|
|
100
|
+
toolNames: {
|
|
101
|
+
send_to: CODEX_CANON_SEND_TO_TOOL_NAME,
|
|
102
|
+
list_contacts: CODEX_CANON_LIST_CONTACTS_TOOL_NAME,
|
|
103
|
+
list_contact_requests: CODEX_CANON_LIST_CONTACT_REQUESTS_TOOL_NAME,
|
|
104
|
+
list_conversations: CODEX_CANON_LIST_CONVERSATIONS_TOOL_NAME,
|
|
105
|
+
cancel_contact_request: CODEX_CANON_CANCEL_CONTACT_REQUEST_TOOL_NAME,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const CODEX_CANON_FOREGROUND_TOOL_NAMES = new Set(CODEX_CANON_COMMUNICATION_BINDING.tools.map((definition) => definition.name));
|
|
93
109
|
/**
|
|
94
110
|
* What the model actually sees for the tool above, for prompt text that names
|
|
95
111
|
* it (the group posture cue). Only meaningful on the app-server transport —
|
|
@@ -121,6 +137,7 @@ const CANON_RUNTIME_CONTROL_TOOL = tool(CANON_RUNTIME_CONTROL_TOOL_NAME, 'Intera
|
|
|
121
137
|
},
|
|
122
138
|
required: ['action'],
|
|
123
139
|
}, false);
|
|
140
|
+
const CODEX_CANON_COMMUNICATION_TOOLS = CODEX_CANON_COMMUNICATION_BINDING.tools.map((definition) => tool(definition.name, definition.description, definition.inputSchema, false));
|
|
124
141
|
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
142
|
+ 'chats when you have nothing to add — no message is created, so no other '
|
|
126
143
|
+ 'member or agent is triggered. Optional private reason (logged, never '
|
|
@@ -244,6 +261,7 @@ export const CODEX_APP_DYNAMIC_TOOLS = [
|
|
|
244
261
|
},
|
|
245
262
|
required: ['threadId', 'title'],
|
|
246
263
|
}),
|
|
264
|
+
...CODEX_CANON_COMMUNICATION_TOOLS,
|
|
247
265
|
CANON_RUNTIME_CONTROL_TOOL,
|
|
248
266
|
// Canon's `no_reply` verb, projected into the only model-visible tool surface
|
|
249
267
|
// the Codex transport gives us. It is answered by the Canon host itself.
|
|
@@ -260,7 +278,8 @@ export const CODEX_SERVICE_AGENT_DYNAMIC_TOOLS = [
|
|
|
260
278
|
* human message; a child thread has neither that turn nor its responder.
|
|
261
279
|
*/
|
|
262
280
|
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
|
|
281
|
+
&& entry.name !== CODEX_NO_REPLY_TOOL_NAME
|
|
282
|
+
&& !CODEX_CANON_FOREGROUND_TOOL_NAMES.has(entry.name)));
|
|
264
283
|
const CODEX_APP_TOOL_NAMES = new Set(CODEX_APP_DYNAMIC_TOOLS.map((entry) => String(entry.name)));
|
|
265
284
|
const UNSUPPORTED_TOOLS = new Map([
|
|
266
285
|
['automation_update', 'Canon does not manage Codex Desktop automations.'],
|
|
@@ -294,10 +313,53 @@ export function isCodexNoReplyToolCall(params) {
|
|
|
294
313
|
export function isCanonRuntimeControlToolCall(params) {
|
|
295
314
|
return normalizeToolName(params.tool) === CANON_RUNTIME_CONTROL_TOOL_NAME;
|
|
296
315
|
}
|
|
316
|
+
export function codexCanonVerbForToolCall(params) {
|
|
317
|
+
const toolName = normalizeToolName(params.tool);
|
|
318
|
+
return toolName ? CODEX_CANON_COMMUNICATION_BINDING.verbForToolName(toolName) : null;
|
|
319
|
+
}
|
|
320
|
+
export function isCodexCanonVerbToolCall(params) {
|
|
321
|
+
return codexCanonVerbForToolCall(params) !== null;
|
|
322
|
+
}
|
|
297
323
|
export function isCodexServiceAgentToolCall(params) {
|
|
298
324
|
const toolName = normalizeToolName(params.tool);
|
|
299
325
|
return toolName === CANON_RUNTIME_CONTROL_TOOL_NAME || toolName === CODEX_NO_REPLY_TOOL_NAME;
|
|
300
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Execute the deliberately filtered canonical-verb projection. The model can
|
|
329
|
+
* author only the destination and visible copy. Provenance and turn metadata
|
|
330
|
+
* come exclusively from the active host turn, so a prompt cannot forge its
|
|
331
|
+
* source or make an outbound message look like an unrelated event.
|
|
332
|
+
*/
|
|
333
|
+
export async function answerCodexCanonVerb(input) {
|
|
334
|
+
const verb = codexCanonVerbForToolCall(input.params);
|
|
335
|
+
if (!verb)
|
|
336
|
+
return toolResult(false, { error: 'Unsupported Canon verb tool.' });
|
|
337
|
+
try {
|
|
338
|
+
const result = await CODEX_CANON_COMMUNICATION_BINDING.execute({
|
|
339
|
+
client: input.client,
|
|
340
|
+
toolName: normalizeToolName(input.params.tool) ?? '',
|
|
341
|
+
arguments: input.params.arguments,
|
|
342
|
+
context: input.context.sourceMessageId ? {
|
|
343
|
+
isOwnerTurn: true,
|
|
344
|
+
conversationId: input.context.conversationId,
|
|
345
|
+
sourceMessageId: input.context.sourceMessageId,
|
|
346
|
+
...(input.context.turnId ? { turnId: input.context.turnId } : {}),
|
|
347
|
+
...(input.context.replyContactTarget
|
|
348
|
+
? { replyContactTarget: input.context.replyContactTarget }
|
|
349
|
+
: {}),
|
|
350
|
+
} : null,
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
success: result.isError !== true,
|
|
354
|
+
contentItems: result.content.map((item) => ({ type: 'inputText', text: item.text })),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
return toolResult(false, {
|
|
359
|
+
error: error instanceof Error ? error.message : String(error),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
301
363
|
export function parseCanonRuntimeControlRequest(params) {
|
|
302
364
|
if (!isCanonRuntimeControlToolCall(params))
|
|
303
365
|
return null;
|
|
@@ -386,6 +448,12 @@ export async function handleCodexAppToolCall(runtime, params) {
|
|
|
386
448
|
error: 'canon_runtime_control is answered by the Canon host, not the app-tool bridge.',
|
|
387
449
|
});
|
|
388
450
|
}
|
|
451
|
+
if (CODEX_CANON_FOREGROUND_TOOL_NAMES.has(toolName)) {
|
|
452
|
+
return toolResult(false, {
|
|
453
|
+
tool: toolName,
|
|
454
|
+
error: `${toolName} is answered by the Canon host, not the app-tool bridge.`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
389
457
|
const unsupportedReason = UNSUPPORTED_TOOLS.get(toolName);
|
|
390
458
|
if (unsupportedReason) {
|
|
391
459
|
return toolResult(false, { tool: toolName, error: unsupportedReason });
|
|
@@ -634,6 +702,21 @@ function parseToolArguments(value) {
|
|
|
634
702
|
}
|
|
635
703
|
return {};
|
|
636
704
|
}
|
|
705
|
+
function parseToolArgumentsStrict(value) {
|
|
706
|
+
if (value === undefined || value === null || value === '')
|
|
707
|
+
return {};
|
|
708
|
+
if (isRecord(value))
|
|
709
|
+
return value;
|
|
710
|
+
if (typeof value !== 'string' || !value.trim())
|
|
711
|
+
return null;
|
|
712
|
+
try {
|
|
713
|
+
const parsed = JSON.parse(value);
|
|
714
|
+
return isRecord(parsed) ? parsed : null;
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
637
720
|
function normalizeToolName(value) {
|
|
638
721
|
if (typeof value !== 'string' || !value.trim())
|
|
639
722
|
return null;
|
package/dist/host.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { type TurnArtifactRoutingDecision, type TurnArtifactRoutingMode } from '@canonmsg/coding-agent-host';
|
|
2
|
+
import { type TurnArtifactRoutingDecision, type TurnArtifactRoutingMode, type RecoveryCheckpointTracker } from '@canonmsg/coding-agent-host';
|
|
3
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
4
|
import { type CodexSandboxMode } from './adapter.js';
|
|
5
5
|
import { type CodexSkillMetadata } from './app-server-adapter.js';
|
|
@@ -36,6 +36,20 @@ export declare function buildCodexLiveSessionConfig(input: {
|
|
|
36
36
|
permissionMode?: string | undefined;
|
|
37
37
|
model?: string | undefined;
|
|
38
38
|
};
|
|
39
|
+
export declare function buildCodexServiceSnapshotConfig(input: {
|
|
40
|
+
model?: string;
|
|
41
|
+
permissionMode?: string;
|
|
42
|
+
effort?: string;
|
|
43
|
+
workspaceId?: string;
|
|
44
|
+
}): {
|
|
45
|
+
model: string | undefined;
|
|
46
|
+
permissionMode: string | undefined;
|
|
47
|
+
effort: string | undefined;
|
|
48
|
+
workspaceId: string | undefined;
|
|
49
|
+
executionMode: "locked";
|
|
50
|
+
executionBranch: null;
|
|
51
|
+
};
|
|
52
|
+
export declare function createCodexRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
|
|
39
53
|
/** Conservative fallback used only when native app-server discovery is unavailable. */
|
|
40
54
|
export declare const CODEX_EFFORT_OPTIONS: readonly CodexControlOption[];
|
|
41
55
|
export declare const CODEX_SESSION_CONFIG_FIELDS: readonly ["permissionMode", "effort"];
|
|
@@ -56,7 +70,9 @@ export declare function buildCodexRuntimeDescriptor(input: {
|
|
|
56
70
|
supportsPlanMode: boolean;
|
|
57
71
|
supportsCompact?: boolean;
|
|
58
72
|
supportsRichCards?: boolean;
|
|
73
|
+
supportsCanonCommunicationTools?: boolean;
|
|
59
74
|
skills?: ReadonlyArray<CodexSkillMetadata>;
|
|
75
|
+
serviceAgentMode?: boolean;
|
|
60
76
|
}): CanonRuntimeDescriptor;
|
|
61
77
|
export declare function buildCodexTurnResponseRouting(input: {
|
|
62
78
|
requestingUserId?: string | null;
|
|
@@ -73,6 +89,7 @@ export declare function getCodexRequestingUserId(message: {
|
|
|
73
89
|
export declare function resolveSessionExecutionMode(config: {
|
|
74
90
|
executionMode?: ExecutionEnvironmentMode;
|
|
75
91
|
} | null | undefined, serviceAgentMode?: boolean): ExecutionEnvironmentMode;
|
|
92
|
+
export declare function resolveCodexSessionConfig<T extends Record<string, unknown>>(config: T | null | undefined, serviceAgentMode?: boolean): T | null;
|
|
76
93
|
export declare function resolveWorkspaceCwd(config: {
|
|
77
94
|
workspaceId?: string;
|
|
78
95
|
retiredWorkspaceConfig?: boolean;
|
|
@@ -95,6 +112,19 @@ export declare function resolveCodexEffectiveRuntimePolicy(input: {
|
|
|
95
112
|
environment: Pick<PreparedExecutionEnvironment, 'baseCwd' | 'mode'>;
|
|
96
113
|
serviceAgentMode?: boolean;
|
|
97
114
|
}): CodexEffectiveRuntimePolicy;
|
|
115
|
+
export declare function resolveCodexPlanCommand(input: {
|
|
116
|
+
content: string;
|
|
117
|
+
requestedPlanMode: boolean;
|
|
118
|
+
useAppServer: boolean;
|
|
119
|
+
serviceAgentMode: boolean;
|
|
120
|
+
}): {
|
|
121
|
+
planMode: boolean;
|
|
122
|
+
content: string;
|
|
123
|
+
};
|
|
124
|
+
export declare function isCodexPlanApprovalReply(metadata: unknown, serviceAgentMode?: boolean): metadata is Record<string, unknown> & {
|
|
125
|
+
type: 'plan_approval_reply';
|
|
126
|
+
decision: string;
|
|
127
|
+
};
|
|
98
128
|
/**
|
|
99
129
|
* `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
|
|
100
130
|
* per-conversation-type default".
|
package/dist/host.js
CHANGED
|
@@ -5,12 +5,12 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { dirname } from 'node:path';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
8
|
-
import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, collectMissedInboundMessages,
|
|
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';
|
|
8
|
+
import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, collectMissedInboundMessages, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, 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, HOST_ADMISSION_ACTIONS_DISABLED, 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, formatPendingContactLifecycleContext, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, readLocalRuntimeEntry, reconcileContactLifecycleEvents, recordLocalRuntimeContactLifecycleEvent, saveLocalRuntimeContactLifecycleCursor, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, shouldTriggerAgentTurn, takeLocalRuntimeContactLifecycleEvents, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
10
10
|
import { validateCard } from '@canonmsg/rich-cards';
|
|
11
11
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
12
12
|
import { CodexAppServerAdapter, } from './app-server-adapter.js';
|
|
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
|
+
import { CODEX_APP_DYNAMIC_TOOLS, CODEX_NO_REPLY_MODEL_TOOL_NAME, CODEX_SERVICE_AGENT_DYNAMIC_TOOLS, answerCodexCanonVerb, answerCodexNoReply, classifyCodexAppToolRequest, deniedCodexAppToolResult, handleCodexAppToolCall, isCanonRuntimeControlToolCall, isCodexAppToolCall, isCodexCanonVerbToolCall, isCodexServiceAgentToolCall, parseCanonRuntimeControlRequest, readCodexNoReplyReason, successfulCodexAppToolResult, } from './codex-app-tools.js';
|
|
14
14
|
import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
|
|
15
15
|
import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
|
|
16
16
|
import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
|
|
@@ -78,7 +78,24 @@ export function buildCodexLiveSessionConfig(input) {
|
|
|
78
78
|
executionBranch: input.executionBranch ?? null,
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
+
export function buildCodexServiceSnapshotConfig(input) {
|
|
82
|
+
// Keep every setup key present, including keys whose host value is
|
|
83
|
+
// undefined. `publishHostSessionSnapshots` merges this over any persisted
|
|
84
|
+
// coding config, so an absent host value clears rather than resurrects a
|
|
85
|
+
// stale member-selected value.
|
|
86
|
+
return {
|
|
87
|
+
model: input.model,
|
|
88
|
+
permissionMode: input.permissionMode,
|
|
89
|
+
effort: input.effort,
|
|
90
|
+
workspaceId: input.workspaceId,
|
|
91
|
+
executionMode: 'locked',
|
|
92
|
+
executionBranch: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
81
95
|
const MAX_SESSIONS = 12;
|
|
96
|
+
export function createCodexRecoveryCheckpointTracker(persist) {
|
|
97
|
+
return createRecoveryCheckpointTracker(persist);
|
|
98
|
+
}
|
|
82
99
|
// IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
|
|
83
100
|
const HEARTBEAT_MS = 30_000;
|
|
84
101
|
/** How Codex says it finished the work but Canon would not take the answer. */
|
|
@@ -138,6 +155,7 @@ export function buildCodexSkillCommands(skills) {
|
|
|
138
155
|
});
|
|
139
156
|
}
|
|
140
157
|
export function buildCodexRuntimeDescriptor(input) {
|
|
158
|
+
const serviceAgentMode = input.serviceAgentMode === true;
|
|
141
159
|
const commands = [
|
|
142
160
|
{
|
|
143
161
|
id: 'runtime-status',
|
|
@@ -157,7 +175,7 @@ export function buildCodexRuntimeDescriptor(input) {
|
|
|
157
175
|
RUNTIME_STOP_ACTION,
|
|
158
176
|
RUNTIME_STOP_AND_DROP_ACTION,
|
|
159
177
|
];
|
|
160
|
-
if (input.supportsCompact) {
|
|
178
|
+
if (input.supportsCompact && !serviceAgentMode) {
|
|
161
179
|
commands.push({
|
|
162
180
|
id: 'codex-compact-context',
|
|
163
181
|
label: 'Compact',
|
|
@@ -177,20 +195,25 @@ export function buildCodexRuntimeDescriptor(input) {
|
|
|
177
195
|
}
|
|
178
196
|
const descriptor = buildFirstPartyCodingRuntimeDescriptor({
|
|
179
197
|
clientType: 'codex',
|
|
180
|
-
models: input.models,
|
|
181
|
-
workspaces: input.workspaces,
|
|
182
|
-
workspaceRoots: input.workspaceRoots,
|
|
183
|
-
executionModes: input.executionModes,
|
|
184
|
-
permissionModes: input.permissionModes,
|
|
185
|
-
defaultPermissionMode: input.defaultPermissionMode,
|
|
198
|
+
models: serviceAgentMode ? [] : input.models,
|
|
199
|
+
workspaces: serviceAgentMode ? [] : input.workspaces,
|
|
200
|
+
workspaceRoots: serviceAgentMode ? undefined : input.workspaceRoots,
|
|
201
|
+
executionModes: serviceAgentMode ? [] : input.executionModes,
|
|
202
|
+
permissionModes: serviceAgentMode ? [] : input.permissionModes,
|
|
203
|
+
defaultPermissionMode: serviceAgentMode ? undefined : input.defaultPermissionMode,
|
|
186
204
|
permissionModeLabel: 'Execution policy',
|
|
187
205
|
modelLiveBehavior: 'next_turn',
|
|
188
|
-
effortOptions:
|
|
189
|
-
|
|
206
|
+
effortOptions: serviceAgentMode
|
|
207
|
+
? []
|
|
208
|
+
: input.effortOptions ?? [...CODEX_EFFORT_OPTIONS],
|
|
209
|
+
defaultEffort: serviceAgentMode ? undefined : input.defaultEffort ?? 'medium',
|
|
190
210
|
effortLiveBehavior: 'next_turn',
|
|
191
211
|
presentation: input.presentation,
|
|
192
212
|
streamingTextMode: 'snapshot',
|
|
193
|
-
|
|
213
|
+
admissionActions: input.supportsCanonCommunicationTools && !serviceAgentMode
|
|
214
|
+
? { ...HOST_ADMISSION_ACTIONS_DISABLED, requestContact: true, reachOut: true }
|
|
215
|
+
: HOST_ADMISSION_ACTIONS_DISABLED,
|
|
216
|
+
...(input.supportsPlanMode && !serviceAgentMode
|
|
194
217
|
? {
|
|
195
218
|
turnModes: [
|
|
196
219
|
{
|
|
@@ -220,7 +243,7 @@ export function buildCodexRuntimeDescriptor(input) {
|
|
|
220
243
|
rich: {
|
|
221
244
|
schema: 'canon.card.v1',
|
|
222
245
|
lifecycle: 'blocking_requires_action',
|
|
223
|
-
responder: 'agent_owner',
|
|
246
|
+
...(serviceAgentMode ? {} : { responder: 'agent_owner' }),
|
|
224
247
|
result: 'action_or_values',
|
|
225
248
|
maxTimeoutMs: 30 * 60_000,
|
|
226
249
|
blockKinds: ['summary', 'metricGrid', 'chart', 'table', 'list', 'callout', 'actions'],
|
|
@@ -231,6 +254,13 @@ export function buildCodexRuntimeDescriptor(input) {
|
|
|
231
254
|
}
|
|
232
255
|
: {}),
|
|
233
256
|
});
|
|
257
|
+
if (serviceAgentMode) {
|
|
258
|
+
const { runtimeControls: _runtimeControls, ...serviceDescriptor } = descriptor;
|
|
259
|
+
return {
|
|
260
|
+
...serviceDescriptor,
|
|
261
|
+
coreControls: [],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
234
264
|
if (input.models.length > 0) {
|
|
235
265
|
return descriptor;
|
|
236
266
|
}
|
|
@@ -254,12 +284,13 @@ export function getCodexRequestingUserId(message) {
|
|
|
254
284
|
async function publishAgentRuntime(agentId, runtime, rtdb) {
|
|
255
285
|
await publishHostAgentRuntime(agentId, 'codex', runtime, rtdb);
|
|
256
286
|
}
|
|
257
|
-
async function loadSessionConfig(conversationId, agentId, rtdb) {
|
|
287
|
+
async function loadSessionConfig(conversationId, agentId, rtdb, retryMissing = true) {
|
|
258
288
|
return loadHostSessionConfig({
|
|
259
289
|
conversationId,
|
|
260
290
|
agentId,
|
|
261
291
|
rtdb,
|
|
262
292
|
extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
|
|
293
|
+
retryMissingMs: retryMissing ? 3_000 : 0,
|
|
263
294
|
});
|
|
264
295
|
}
|
|
265
296
|
export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
|
|
@@ -269,6 +300,9 @@ export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
|
|
|
269
300
|
return config.executionMode;
|
|
270
301
|
throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
|
|
271
302
|
}
|
|
303
|
+
export function resolveCodexSessionConfig(config, serviceAgentMode = false) {
|
|
304
|
+
return serviceAgentMode ? null : config ?? null;
|
|
305
|
+
}
|
|
272
306
|
export function resolveWorkspaceCwd(config, serviceAgentMode = false) {
|
|
273
307
|
return resolveHostWorkspaceCwd({
|
|
274
308
|
workspaceOptions,
|
|
@@ -311,6 +345,9 @@ export function resolveCodexEffectiveRuntimePolicy(input) {
|
|
|
311
345
|
baseCwd: input.environment.baseCwd,
|
|
312
346
|
executionMode: input.environment.mode,
|
|
313
347
|
permissionMode: permissionMode ?? null,
|
|
348
|
+
...(input.serviceAgentMode
|
|
349
|
+
? { serviceAgentMode: true, model: model ?? null }
|
|
350
|
+
: {}),
|
|
314
351
|
sandbox,
|
|
315
352
|
// `--ask-for-approval` is rejected at startup, so this was always null.
|
|
316
353
|
// The key must stay: it is hashed into every persisted thread fingerprint.
|
|
@@ -340,8 +377,8 @@ function buildCanonPrompt(input) {
|
|
|
340
377
|
message: input.message,
|
|
341
378
|
})), input.noReplyToolName ? { noReplyToolName: input.noReplyToolName } : {});
|
|
342
379
|
}
|
|
343
|
-
function renderInboundContent(message, materialized) {
|
|
344
|
-
return renderCanonHostInboundContent(message, materialized);
|
|
380
|
+
function renderInboundContent(message, materialized, options) {
|
|
381
|
+
return renderCanonHostInboundContent(message, materialized, options);
|
|
345
382
|
}
|
|
346
383
|
async function materializePromptReplyContext(input) {
|
|
347
384
|
if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
|
|
@@ -358,6 +395,16 @@ async function materializePromptReplyContext(input) {
|
|
|
358
395
|
return { replyContext: input.replyContext, materialized: [] };
|
|
359
396
|
}
|
|
360
397
|
}
|
|
398
|
+
function ownerBoundReplyContactTarget(replyContext) {
|
|
399
|
+
const card = replyContext?.found ? replyContext.contactCard : undefined;
|
|
400
|
+
if (!card?.userId)
|
|
401
|
+
return undefined;
|
|
402
|
+
return {
|
|
403
|
+
targetUserId: card.userId,
|
|
404
|
+
...(card.canonContactId ? { canonContactId: card.canonContactId } : {}),
|
|
405
|
+
sourceCardMessageId: replyContext.messageId,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
361
408
|
function summarizeCommand(command) {
|
|
362
409
|
const trimmed = command.trim();
|
|
363
410
|
if (!trimmed)
|
|
@@ -400,6 +447,14 @@ function parsePlanCommand(content) {
|
|
|
400
447
|
content: rest || 'Please inspect the request and propose a plan before making changes.',
|
|
401
448
|
};
|
|
402
449
|
}
|
|
450
|
+
export function resolveCodexPlanCommand(input) {
|
|
451
|
+
if (!input.useAppServer || input.serviceAgentMode) {
|
|
452
|
+
return { planMode: false, content: input.content };
|
|
453
|
+
}
|
|
454
|
+
return input.requestedPlanMode
|
|
455
|
+
? { planMode: true, content: input.content }
|
|
456
|
+
: parsePlanCommand(input.content);
|
|
457
|
+
}
|
|
403
458
|
function mapCodexQuestions(value) {
|
|
404
459
|
if (!Array.isArray(value))
|
|
405
460
|
return undefined;
|
|
@@ -453,6 +508,12 @@ function mapCodexQuestions(value) {
|
|
|
453
508
|
function isRecord(value) {
|
|
454
509
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
455
510
|
}
|
|
511
|
+
export function isCodexPlanApprovalReply(metadata, serviceAgentMode = false) {
|
|
512
|
+
return !serviceAgentMode
|
|
513
|
+
&& isRecord(metadata)
|
|
514
|
+
&& metadata.type === 'plan_approval_reply'
|
|
515
|
+
&& typeof metadata.decision === 'string';
|
|
516
|
+
}
|
|
456
517
|
function readString(record, key) {
|
|
457
518
|
const value = record[key];
|
|
458
519
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
@@ -772,7 +833,15 @@ export async function main() {
|
|
|
772
833
|
});
|
|
773
834
|
const sessions = new Map();
|
|
774
835
|
const pendingSessionCreations = new Map();
|
|
775
|
-
|
|
836
|
+
const recoveryCheckpointTrackers = new Map();
|
|
837
|
+
function recoveryCheckpointsFor(conversationId) {
|
|
838
|
+
let tracker = recoveryCheckpointTrackers.get(conversationId);
|
|
839
|
+
if (!tracker) {
|
|
840
|
+
tracker = createCodexRecoveryCheckpointTracker((messageId) => persistInboundRecoveryCursor(conversationId, messageId));
|
|
841
|
+
recoveryCheckpointTrackers.set(conversationId, tracker);
|
|
842
|
+
}
|
|
843
|
+
return tracker;
|
|
844
|
+
}
|
|
776
845
|
const conversationCache = new Map();
|
|
777
846
|
const knownConversationIds = new Set();
|
|
778
847
|
const promptedGroupContextConversationIds = new Set();
|
|
@@ -858,6 +927,7 @@ export async function main() {
|
|
|
858
927
|
ownerName,
|
|
859
928
|
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
860
929
|
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
930
|
+
renderOptions: input.renderOptions,
|
|
861
931
|
});
|
|
862
932
|
}
|
|
863
933
|
function writeState(session) {
|
|
@@ -920,27 +990,20 @@ export async function main() {
|
|
|
920
990
|
}
|
|
921
991
|
function persistInboundRecoveryCursor(conversationId, messageId) {
|
|
922
992
|
if (!messageId)
|
|
923
|
-
return;
|
|
993
|
+
return true;
|
|
924
994
|
try {
|
|
925
995
|
saveRuntimeSessionState(runtimeId, {
|
|
926
996
|
conversationId,
|
|
927
997
|
baseCwd: workingDir,
|
|
928
998
|
lastInboundMessageId: messageId,
|
|
929
999
|
});
|
|
1000
|
+
return true;
|
|
930
1001
|
}
|
|
931
1002
|
catch (error) {
|
|
932
1003
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
|
|
1004
|
+
return false;
|
|
933
1005
|
}
|
|
934
1006
|
}
|
|
935
|
-
function persistInboundRecoveryCursorWhenIdle(conversationId, messageId) {
|
|
936
|
-
const session = sessions.get(conversationId);
|
|
937
|
-
// A later queued turn will advance past this non-triggering message. Do
|
|
938
|
-
// not write it ahead of earlier work and then let that work regress the
|
|
939
|
-
// cursor when it completes.
|
|
940
|
-
if (session?.running || (session?.queue.length ?? 0) > 0)
|
|
941
|
-
return;
|
|
942
|
-
persistInboundRecoveryCursor(conversationId, messageId);
|
|
943
|
-
}
|
|
944
1007
|
async function markQueuedPromptsRejected(conversationId, prompts) {
|
|
945
1008
|
await Promise.all(prompts.map((prompt) => {
|
|
946
1009
|
if (!prompt.markAccepted || !prompt.sourceMessageId)
|
|
@@ -948,27 +1011,21 @@ export async function main() {
|
|
|
948
1011
|
return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
|
|
949
1012
|
}));
|
|
950
1013
|
}
|
|
951
|
-
function
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
if (!latest)
|
|
956
|
-
return;
|
|
957
|
-
if (session.running) {
|
|
958
|
-
session.pendingDroppedRecoveryCursor = latest;
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
persistInboundRecoveryCursor(session.conversationId, latest);
|
|
1014
|
+
function settleRejectedPromptCheckpoints(conversationId, prompts) {
|
|
1015
|
+
const checkpoints = recoveryCheckpointsFor(conversationId);
|
|
1016
|
+
for (const prompt of prompts)
|
|
1017
|
+
checkpoints.settle(prompt.sourceMessageId);
|
|
962
1018
|
}
|
|
963
1019
|
function removeQueuedPrompt(conversationId, sourceMessageId) {
|
|
964
1020
|
const session = sessions.get(conversationId);
|
|
965
1021
|
if (!session || session.queue.length === 0)
|
|
966
1022
|
return;
|
|
967
|
-
const
|
|
1023
|
+
const removed = session.queue.filter((prompt) => prompt.sourceMessageId === sourceMessageId);
|
|
1024
|
+
if (removed.length === 0)
|
|
1025
|
+
return;
|
|
968
1026
|
session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
}
|
|
1027
|
+
settleRejectedPromptCheckpoints(conversationId, removed);
|
|
1028
|
+
writeTurn(session);
|
|
972
1029
|
}
|
|
973
1030
|
function clearStreaming(conversationId) {
|
|
974
1031
|
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
@@ -1152,7 +1209,7 @@ export async function main() {
|
|
|
1152
1209
|
session.resetRequested = true;
|
|
1153
1210
|
const droppedPrompts = session.queue.splice(0);
|
|
1154
1211
|
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
1155
|
-
|
|
1212
|
+
settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
|
|
1156
1213
|
clearStoredThreadId(runtimeId, conversationId, session.environment.baseCwd, session.environment.mode);
|
|
1157
1214
|
session.adapter.clearThreadId();
|
|
1158
1215
|
session.activeSelfContextId = null;
|
|
@@ -1168,6 +1225,7 @@ export async function main() {
|
|
|
1168
1225
|
session.currentTurnOpenedAt = null;
|
|
1169
1226
|
session.currentTurnUpdatedAt = null;
|
|
1170
1227
|
session.currentTurnCanUseCodexAppTools = false;
|
|
1228
|
+
session.currentTurnCanUseCodexCanonTools = false;
|
|
1171
1229
|
session.currentTurnSilenced = false;
|
|
1172
1230
|
session.lastAcceptedIntent = null;
|
|
1173
1231
|
session.resetRequested = false;
|
|
@@ -1205,7 +1263,7 @@ export async function main() {
|
|
|
1205
1263
|
evictOldestIdle();
|
|
1206
1264
|
}
|
|
1207
1265
|
const creation = (async () => {
|
|
1208
|
-
const config = await loadSessionConfig(conversationId, agentId, rtdb);
|
|
1266
|
+
const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb, !serviceAgentMode), serviceAgentMode);
|
|
1209
1267
|
const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode);
|
|
1210
1268
|
const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
|
|
1211
1269
|
const environment = prepareConversationEnvironment({
|
|
@@ -1280,6 +1338,8 @@ export async function main() {
|
|
|
1280
1338
|
currentTurnOpenedAt: null,
|
|
1281
1339
|
currentTurnUpdatedAt: null,
|
|
1282
1340
|
currentTurnCanUseCodexAppTools: false,
|
|
1341
|
+
currentTurnCanUseCodexCanonTools: false,
|
|
1342
|
+
currentTurnReplyContactTarget: null,
|
|
1283
1343
|
currentTurnAbortController: null,
|
|
1284
1344
|
// Corrected by the first turn that runs; a session with no turn
|
|
1285
1345
|
// publishes nothing anyway, and quiet is never an accident.
|
|
@@ -1287,7 +1347,6 @@ export async function main() {
|
|
|
1287
1347
|
currentTurnSilenced: false,
|
|
1288
1348
|
activeSelfContextId: null,
|
|
1289
1349
|
lastAcceptedIntent: null,
|
|
1290
|
-
pendingDroppedRecoveryCursor: null,
|
|
1291
1350
|
resetRequested: false,
|
|
1292
1351
|
lastActivity: Date.now(),
|
|
1293
1352
|
typingKeepaliveTimer: null,
|
|
@@ -1327,9 +1386,10 @@ export async function main() {
|
|
|
1327
1386
|
planMode,
|
|
1328
1387
|
artifactRoutingMode: turn.artifactRoutingMode ?? 'disabled',
|
|
1329
1388
|
canUseCodexAppTools: turn.canUseCodexAppTools ?? false,
|
|
1389
|
+
canUseCodexCanonTools: turn.canUseCodexCanonTools ?? false,
|
|
1330
1390
|
...(turn.turnVerbosity ? { turnVerbosity: turn.turnVerbosity } : {}),
|
|
1331
1391
|
requestingUserId: turn.requestingUserId ?? null,
|
|
1332
|
-
|
|
1392
|
+
...(turn.replyContactTarget ? { replyContactTarget: turn.replyContactTarget } : {}),
|
|
1333
1393
|
};
|
|
1334
1394
|
if (toFront) {
|
|
1335
1395
|
session.queue.unshift(nextPrompt);
|
|
@@ -1360,6 +1420,38 @@ export async function main() {
|
|
|
1360
1420
|
requestingUserId: responseUserId,
|
|
1361
1421
|
});
|
|
1362
1422
|
}
|
|
1423
|
+
function recordContactLifecycleEvent(request) {
|
|
1424
|
+
if (request.requesterId !== agentId || !request.sourceConversationId)
|
|
1425
|
+
return false;
|
|
1426
|
+
return recordLocalRuntimeContactLifecycleEvent(runtimeId, request);
|
|
1427
|
+
}
|
|
1428
|
+
async function reconcileContactLifecycleInbox() {
|
|
1429
|
+
let cursor = readLocalRuntimeEntry(runtimeId)?.contactLifecycleCursor ?? null;
|
|
1430
|
+
let recorded = 0;
|
|
1431
|
+
let duplicate = 0;
|
|
1432
|
+
let ignored = 0;
|
|
1433
|
+
for (let pageNumber = 0; pageNumber < 10; pageNumber += 1) {
|
|
1434
|
+
const page = await client.listContactRequestLifecyclePage({
|
|
1435
|
+
cursor,
|
|
1436
|
+
limit: 100,
|
|
1437
|
+
});
|
|
1438
|
+
const result = await reconcileContactLifecycleEvents({
|
|
1439
|
+
requests: page.requests,
|
|
1440
|
+
requesterId: agentId,
|
|
1441
|
+
record: (request) => recordLocalRuntimeContactLifecycleEvent(runtimeId, request),
|
|
1442
|
+
});
|
|
1443
|
+
recorded += result.recorded;
|
|
1444
|
+
duplicate += result.duplicate;
|
|
1445
|
+
ignored += result.ignored;
|
|
1446
|
+
cursor = page.nextCursor;
|
|
1447
|
+
saveLocalRuntimeContactLifecycleCursor(runtimeId, cursor);
|
|
1448
|
+
if (!page.hasMore)
|
|
1449
|
+
break;
|
|
1450
|
+
}
|
|
1451
|
+
if (recorded > 0) {
|
|
1452
|
+
console.error(`[canon-codex] Contact lifecycle recovery: recorded=${recorded} duplicate=${duplicate} ignored=${ignored}`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1363
1455
|
function resolveArtifactRoutingMode(participantContext) {
|
|
1364
1456
|
return participantContext.conversationType === 'direct' && participantContext.isOwner
|
|
1365
1457
|
? 'workspace-generated'
|
|
@@ -1371,15 +1463,19 @@ export async function main() {
|
|
|
1371
1463
|
* so a prompt that waits behind another still runs under the answer it
|
|
1372
1464
|
* arrived with.
|
|
1373
1465
|
*/
|
|
1374
|
-
function resolveCodexTurnModes(participantContext, message) {
|
|
1466
|
+
function resolveCodexTurnModes(participantContext, message, replyContext) {
|
|
1375
1467
|
return {
|
|
1376
1468
|
artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
|
|
1377
1469
|
canUseCodexAppTools: participantContext.isOwner || serviceAgentMode,
|
|
1470
|
+
canUseCodexCanonTools: participantContext.isOwner && !serviceAgentMode,
|
|
1378
1471
|
turnVerbosity: resolveTurnVerbosity({
|
|
1379
1472
|
configured: configuredTurnVerbosity,
|
|
1380
1473
|
conversationType: participantContext.conversationType,
|
|
1381
1474
|
}),
|
|
1382
1475
|
requestingUserId: getCodexRequestingUserId(message),
|
|
1476
|
+
...(participantContext.isOwner && replyContext
|
|
1477
|
+
? { replyContactTarget: ownerBoundReplyContactTarget(replyContext) }
|
|
1478
|
+
: {}),
|
|
1383
1479
|
};
|
|
1384
1480
|
}
|
|
1385
1481
|
function runtimeCardRequestPayload(method, params) {
|
|
@@ -1421,6 +1517,23 @@ export async function main() {
|
|
|
1421
1517
|
if (disposition === 'denied-non-owner') {
|
|
1422
1518
|
return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
|
|
1423
1519
|
}
|
|
1520
|
+
if (isCodexCanonVerbToolCall(params)) {
|
|
1521
|
+
if (!session.currentTurnCanUseCodexCanonTools) {
|
|
1522
|
+
return deniedCodexAppToolResult('Canon contact and conversation tools require a real owner-authored inbound turn.');
|
|
1523
|
+
}
|
|
1524
|
+
return await answerCodexCanonVerb({
|
|
1525
|
+
client,
|
|
1526
|
+
params,
|
|
1527
|
+
context: {
|
|
1528
|
+
conversationId: session.conversationId,
|
|
1529
|
+
sourceMessageId,
|
|
1530
|
+
turnId: session.currentTurnId,
|
|
1531
|
+
...(session.currentTurnReplyContactTarget
|
|
1532
|
+
? { replyContactTarget: session.currentTurnReplyContactTarget }
|
|
1533
|
+
: {}),
|
|
1534
|
+
},
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1424
1537
|
if (isCanonRuntimeControlToolCall(params)) {
|
|
1425
1538
|
const command = parseCanonRuntimeControlRequest(params);
|
|
1426
1539
|
if (!command) {
|
|
@@ -1715,18 +1828,23 @@ export async function main() {
|
|
|
1715
1828
|
knownConversationIds.add(input.conversationId);
|
|
1716
1829
|
if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
|
|
1717
1830
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
|
|
1718
|
-
|
|
1831
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1719
1832
|
return;
|
|
1720
1833
|
}
|
|
1721
|
-
if (
|
|
1722
|
-
|
|
1723
|
-
|
|
1834
|
+
if (input.message.metadata?.type === 'plan_approval_reply') {
|
|
1835
|
+
if (!isCodexPlanApprovalReply(input.message.metadata, serviceAgentMode)) {
|
|
1836
|
+
// A service agent never advertises or enters plan mode. Consume stale
|
|
1837
|
+
// replies left by an older coding descriptor instead of turning them
|
|
1838
|
+
// into hidden plan/implementation prompts after an upgrade or restart.
|
|
1839
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1724
1842
|
const planId = readString(input.message.metadata, 'planId');
|
|
1725
1843
|
if (planId && runtimeRequests.handleMessage(input.conversationId, {
|
|
1726
1844
|
senderId: input.message.senderId,
|
|
1727
1845
|
metadata: input.message.metadata,
|
|
1728
1846
|
})) {
|
|
1729
|
-
|
|
1847
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1730
1848
|
return;
|
|
1731
1849
|
}
|
|
1732
1850
|
const session = await getOrCreateSession(input.conversationId);
|
|
@@ -1755,14 +1873,16 @@ export async function main() {
|
|
|
1755
1873
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
1756
1874
|
}
|
|
1757
1875
|
}
|
|
1758
|
-
const
|
|
1876
|
+
const renderOptions = resolveInboundContentRenderOptions(input.isOwner);
|
|
1877
|
+
const renderedContent = renderInboundContent(input.message, materialized, renderOptions);
|
|
1759
1878
|
const turnMetadata = normalizeTurnMetadata(input.message.metadata);
|
|
1760
1879
|
const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
|
|
1761
|
-
const planCommand =
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1880
|
+
const planCommand = resolveCodexPlanCommand({
|
|
1881
|
+
content: renderedContent,
|
|
1882
|
+
requestedPlanMode,
|
|
1883
|
+
useAppServer,
|
|
1884
|
+
serviceAgentMode,
|
|
1885
|
+
});
|
|
1766
1886
|
const content = planCommand.content;
|
|
1767
1887
|
const hydrated = await loadHydratedInboundContext({
|
|
1768
1888
|
conversationId: input.conversationId,
|
|
@@ -1773,6 +1893,7 @@ export async function main() {
|
|
|
1773
1893
|
selfContexts: input.selfContexts,
|
|
1774
1894
|
provenance: input.provenance,
|
|
1775
1895
|
hydratedPage: input.hydratedPage,
|
|
1896
|
+
renderOptions,
|
|
1776
1897
|
});
|
|
1777
1898
|
const behavior = input.behavior ?? hydrated.behavior;
|
|
1778
1899
|
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
@@ -1799,7 +1920,7 @@ export async function main() {
|
|
|
1799
1920
|
: decideAutoReply(participantContext, behavior);
|
|
1800
1921
|
if (!autoReply.allow) {
|
|
1801
1922
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
1802
|
-
|
|
1923
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1803
1924
|
return;
|
|
1804
1925
|
}
|
|
1805
1926
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
@@ -1823,11 +1944,11 @@ export async function main() {
|
|
|
1823
1944
|
replyBehavior: 'suppress_auto_reply',
|
|
1824
1945
|
},
|
|
1825
1946
|
}).catch(() => { });
|
|
1826
|
-
|
|
1947
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1827
1948
|
return;
|
|
1828
1949
|
}
|
|
1829
1950
|
session.activeSelfContextId = activeSelfContextId;
|
|
1830
|
-
const
|
|
1951
|
+
const basePrompt = buildCanonPrompt({
|
|
1831
1952
|
content,
|
|
1832
1953
|
conversationId: input.conversationId,
|
|
1833
1954
|
participantContext,
|
|
@@ -1839,8 +1960,12 @@ export async function main() {
|
|
|
1839
1960
|
message: input.message,
|
|
1840
1961
|
...(useAppServer ? { noReplyToolName: CODEX_NO_REPLY_MODEL_TOOL_NAME } : {}),
|
|
1841
1962
|
});
|
|
1963
|
+
const lifecycleContext = input.isOwner
|
|
1964
|
+
? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, input.conversationId))
|
|
1965
|
+
: null;
|
|
1966
|
+
const prompt = lifecycleContext ? `${basePrompt}\n\n${lifecycleContext}` : basePrompt;
|
|
1842
1967
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
1843
|
-
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
|
|
1968
|
+
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message, replyContext));
|
|
1844
1969
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
1845
1970
|
session.currentTurnAbortController?.abort(new Error('Codex turn interrupted by a newer message'));
|
|
1846
1971
|
await session.adapter.interrupt().catch(() => { });
|
|
@@ -1848,7 +1973,7 @@ export async function main() {
|
|
|
1848
1973
|
typingSignals.clear(input.conversationId).catch(() => { });
|
|
1849
1974
|
return;
|
|
1850
1975
|
}
|
|
1851
|
-
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message));
|
|
1976
|
+
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, resolveCodexTurnModes(participantContext, input.message, replyContext));
|
|
1852
1977
|
}
|
|
1853
1978
|
function sendTurnArtifactFile(session, file) {
|
|
1854
1979
|
return sendMediaFileMessage(client, session.conversationId, file.path, '', {
|
|
@@ -1879,6 +2004,8 @@ export async function main() {
|
|
|
1879
2004
|
session.currentTurnOpenedAt = Date.now();
|
|
1880
2005
|
session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
|
|
1881
2006
|
session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
|
|
2007
|
+
session.currentTurnCanUseCodexCanonTools = nextTurn.canUseCodexCanonTools === true;
|
|
2008
|
+
session.currentTurnReplyContactTarget = nextTurn.replyContactTarget ?? null;
|
|
1882
2009
|
session.currentTurnAbortController = new AbortController();
|
|
1883
2010
|
// A continuation prompt (a plan-review result) carries none, and keeps the
|
|
1884
2011
|
// conversation's last answer rather than silently reverting to verbose.
|
|
@@ -2283,11 +2410,7 @@ export async function main() {
|
|
|
2283
2410
|
finally {
|
|
2284
2411
|
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2285
2412
|
session.currentTurnAbortController = null;
|
|
2286
|
-
|
|
2287
|
-
if (session.pendingDroppedRecoveryCursor) {
|
|
2288
|
-
persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
|
|
2289
|
-
session.pendingDroppedRecoveryCursor = null;
|
|
2290
|
-
}
|
|
2413
|
+
recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
|
|
2291
2414
|
stopVisibleWorkSignal(session);
|
|
2292
2415
|
session.running = false;
|
|
2293
2416
|
session.state.state = 'idle';
|
|
@@ -2296,6 +2419,8 @@ export async function main() {
|
|
|
2296
2419
|
session.currentTurnOpenedAt = null;
|
|
2297
2420
|
session.currentTurnUpdatedAt = null;
|
|
2298
2421
|
session.currentTurnCanUseCodexAppTools = false;
|
|
2422
|
+
session.currentTurnCanUseCodexCanonTools = false;
|
|
2423
|
+
session.currentTurnReplyContactTarget = null;
|
|
2299
2424
|
session.currentTurnSilenced = false;
|
|
2300
2425
|
session.lastAcceptedIntent = null;
|
|
2301
2426
|
session.resetRequested = false;
|
|
@@ -2354,14 +2479,18 @@ export async function main() {
|
|
|
2354
2479
|
});
|
|
2355
2480
|
let codexSkills = [];
|
|
2356
2481
|
const buildCurrentRuntimeDescriptor = () => ({
|
|
2357
|
-
|
|
2482
|
+
...(serviceAgentMode
|
|
2483
|
+
? {}
|
|
2484
|
+
: {
|
|
2485
|
+
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
2486
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
2487
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
2488
|
+
availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
2489
|
+
...(codexPermissionEnvelope.defaultPermissionMode
|
|
2490
|
+
? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
|
|
2491
|
+
: {}),
|
|
2492
|
+
}),
|
|
2358
2493
|
...(codexDefaultModel ? { defaultModel: codexDefaultModel } : {}),
|
|
2359
|
-
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
2360
|
-
availableExecutionModes: hostAvailableExecutionModes,
|
|
2361
|
-
availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
2362
|
-
...(codexPermissionEnvelope.defaultPermissionMode
|
|
2363
|
-
? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
|
|
2364
|
-
: {}),
|
|
2365
2494
|
runtimeDescriptor: buildCodexRuntimeDescriptor({
|
|
2366
2495
|
models: codexModelOptions,
|
|
2367
2496
|
effortOptions: codexEffortOptions,
|
|
@@ -2375,10 +2504,25 @@ export async function main() {
|
|
|
2375
2504
|
supportsPlanMode: useAppServer,
|
|
2376
2505
|
supportsCompact: useAppServer,
|
|
2377
2506
|
supportsRichCards: useAppServer,
|
|
2507
|
+
supportsCanonCommunicationTools: useAppServer,
|
|
2378
2508
|
skills: codexSkills,
|
|
2509
|
+
serviceAgentMode,
|
|
2379
2510
|
}),
|
|
2380
2511
|
});
|
|
2381
2512
|
let runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2513
|
+
const resolveInboundContentRenderOptions = (isOwnerTurn) => {
|
|
2514
|
+
const descriptorActions = runtimeDescriptor.runtimeDescriptor?.admissionActions
|
|
2515
|
+
?? HOST_ADMISSION_ACTIONS_DISABLED;
|
|
2516
|
+
const admissionActions = isOwnerTurn && useAppServer && !serviceAgentMode
|
|
2517
|
+
? descriptorActions
|
|
2518
|
+
: HOST_ADMISSION_ACTIONS_DISABLED;
|
|
2519
|
+
return {
|
|
2520
|
+
admissionActions,
|
|
2521
|
+
...(admissionActions.reachOut
|
|
2522
|
+
? { reachOutToolName: 'codex_app.canon_send_to' }
|
|
2523
|
+
: {}),
|
|
2524
|
+
};
|
|
2525
|
+
};
|
|
2382
2526
|
async function refreshCodexSkillInventory(forceReload = false) {
|
|
2383
2527
|
if (!useAppServer)
|
|
2384
2528
|
return;
|
|
@@ -2421,6 +2565,11 @@ export async function main() {
|
|
|
2421
2565
|
const session = sessions.get(conversationId);
|
|
2422
2566
|
if (!session || session.closed)
|
|
2423
2567
|
return;
|
|
2568
|
+
if (serviceAgentMode) {
|
|
2569
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring user session controls for service-agent runtime`);
|
|
2570
|
+
writeState(session);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2424
2573
|
let modelChanged = false;
|
|
2425
2574
|
if (control.model && control.model !== session.state.model) {
|
|
2426
2575
|
if (codexModelOptions.length > 0 && !codexModelOptions.some((option) => option.value === control.model)) {
|
|
@@ -2490,7 +2639,7 @@ export async function main() {
|
|
|
2490
2639
|
if (type === 'stop_and_drop') {
|
|
2491
2640
|
const droppedPrompts = session.queue.splice(0);
|
|
2492
2641
|
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
2493
|
-
|
|
2642
|
+
settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
|
|
2494
2643
|
}
|
|
2495
2644
|
if (session.running) {
|
|
2496
2645
|
session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
|
|
@@ -2577,10 +2726,27 @@ export async function main() {
|
|
|
2577
2726
|
workspaceOptions,
|
|
2578
2727
|
defaultCwd: workingDir,
|
|
2579
2728
|
extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
|
|
2580
|
-
liveSessionConfigByConversation: new Map(Array.from(sessions.
|
|
2729
|
+
liveSessionConfigByConversation: new Map(Array.from(serviceAgentMode ? knownConversationIds : sessions.keys()).map((conversationId) => {
|
|
2730
|
+
const session = sessions.get(conversationId);
|
|
2731
|
+
if (serviceAgentMode) {
|
|
2732
|
+
return [
|
|
2733
|
+
conversationId,
|
|
2734
|
+
buildCodexServiceSnapshotConfig({
|
|
2735
|
+
model: codexDefaultModel ?? session?.state.model ?? undefined,
|
|
2736
|
+
permissionMode: codexPermissionEnvelope.defaultPermissionMode,
|
|
2737
|
+
effort: codexDefaultEffort ?? session?.state.effort ?? undefined,
|
|
2738
|
+
workspaceId: resolveWorkspaceIdForBaseCwd(workingDir) ?? undefined,
|
|
2739
|
+
}),
|
|
2740
|
+
];
|
|
2741
|
+
}
|
|
2742
|
+
if (!session) {
|
|
2743
|
+
// Non-service snapshots are published only for live sessions,
|
|
2744
|
+
// but keep this branch total if that invariant changes.
|
|
2745
|
+
return [conversationId, {}];
|
|
2746
|
+
}
|
|
2581
2747
|
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
2582
2748
|
return [
|
|
2583
|
-
|
|
2749
|
+
conversationId,
|
|
2584
2750
|
buildCodexLiveSessionConfig({
|
|
2585
2751
|
model: session.state.model,
|
|
2586
2752
|
permissionMode: session.state.permissionMode,
|
|
@@ -2668,6 +2834,90 @@ export async function main() {
|
|
|
2668
2834
|
publishRuntimeDetailsInFlight = false;
|
|
2669
2835
|
}
|
|
2670
2836
|
};
|
|
2837
|
+
let startupRecoveryComplete = false;
|
|
2838
|
+
async function recoverInboundMessageGaps() {
|
|
2839
|
+
// A reconnect can reveal conversations created while this host was
|
|
2840
|
+
// offline. Refresh before sweeping cursors; this is one REST reconciliation
|
|
2841
|
+
// pass, not an automatic retry loop.
|
|
2842
|
+
const knownBeforeRefresh = new Set(knownConversationIds);
|
|
2843
|
+
await refreshKnownConversationIds(true);
|
|
2844
|
+
const conversationsDiscoveredWhileOffline = startupRecoveryComplete
|
|
2845
|
+
? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
|
|
2846
|
+
: new Set();
|
|
2847
|
+
try {
|
|
2848
|
+
await reconcileContactLifecycleInbox();
|
|
2849
|
+
}
|
|
2850
|
+
catch (error) {
|
|
2851
|
+
console.error('[canon-codex] Contact lifecycle recovery failed:', error instanceof Error ? error.message : error);
|
|
2852
|
+
}
|
|
2853
|
+
for (const conversationId of knownConversationIds) {
|
|
2854
|
+
const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
|
|
2855
|
+
const recoveryBatch = recoveryCheckpoints.reserveBatch();
|
|
2856
|
+
try {
|
|
2857
|
+
const cursor = loadRuntimeSessionState(runtimeId, {
|
|
2858
|
+
conversationId,
|
|
2859
|
+
baseCwd: workingDir,
|
|
2860
|
+
})?.lastInboundMessageId ?? null;
|
|
2861
|
+
const recovered = await collectMissedInboundMessages({
|
|
2862
|
+
fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
2863
|
+
cursor,
|
|
2864
|
+
agentId,
|
|
2865
|
+
requireContiguousCursor: true,
|
|
2866
|
+
noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
|
|
2867
|
+
? 'bounded-window'
|
|
2868
|
+
: 'latest-only',
|
|
2869
|
+
});
|
|
2870
|
+
recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
|
|
2871
|
+
if (recovered.mode === 'incomplete-gap') {
|
|
2872
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] recovery_incomplete_gap: cursor is missing; replaying ${recovered.messages.length} bounded inbound message(s) before advancing to ${recovered.recoveryCursor ?? 'no cursor'}`);
|
|
2873
|
+
}
|
|
2874
|
+
for (const message of recovered.messages) {
|
|
2875
|
+
const isPlanReply = isCodexPlanApprovalReply(message.metadata, serviceAgentMode);
|
|
2876
|
+
if (!isPlanReply && !shouldTriggerAgentTurn({
|
|
2877
|
+
senderType: message.senderType,
|
|
2878
|
+
metadata: message.metadata,
|
|
2879
|
+
}).allow) {
|
|
2880
|
+
recoveryCheckpoints.settle(message.id);
|
|
2881
|
+
continue;
|
|
2882
|
+
}
|
|
2883
|
+
if (!claimInboundMessageId(message.id))
|
|
2884
|
+
continue;
|
|
2885
|
+
try {
|
|
2886
|
+
await enqueueInboundMessage({
|
|
2887
|
+
conversationId,
|
|
2888
|
+
message,
|
|
2889
|
+
senderName: message.senderName || message.senderId,
|
|
2890
|
+
isOwner: message.senderId === ownerId,
|
|
2891
|
+
behavior: recovered.newestPage.behavior,
|
|
2892
|
+
activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
|
|
2893
|
+
selfContexts: recovered.newestPage.selfContexts,
|
|
2894
|
+
hydratedPage: recovered.newestPage,
|
|
2895
|
+
});
|
|
2896
|
+
settleInboundMessageId(message.id, true);
|
|
2897
|
+
}
|
|
2898
|
+
catch (error) {
|
|
2899
|
+
settleInboundMessageId(message.id, false);
|
|
2900
|
+
throw error;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (recovered.messages.length > 0) {
|
|
2904
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
catch (error) {
|
|
2908
|
+
recoveryBatch.cancel();
|
|
2909
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery failed:`, error instanceof Error ? error.message : error);
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
const reconnectRecovery = createReconnectRecoveryCoordinator(recoverInboundMessageGaps);
|
|
2914
|
+
const observeReconnectRecovery = (reason, recovery) => {
|
|
2915
|
+
if (!recovery)
|
|
2916
|
+
return;
|
|
2917
|
+
void recovery.catch((error) => {
|
|
2918
|
+
console.error(`[canon-codex] ${reason} recovery failed:`, error instanceof Error ? error.message : error);
|
|
2919
|
+
});
|
|
2920
|
+
};
|
|
2671
2921
|
const stream = new CanonStream({
|
|
2672
2922
|
apiKey,
|
|
2673
2923
|
agentId,
|
|
@@ -2679,9 +2929,10 @@ export async function main() {
|
|
|
2679
2929
|
return;
|
|
2680
2930
|
if (!claimInboundMessageId(message.id))
|
|
2681
2931
|
return;
|
|
2932
|
+
recoveryCheckpointsFor(payload.conversationId).track(message.id);
|
|
2682
2933
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2683
2934
|
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2684
|
-
|
|
2935
|
+
recoveryCheckpointsFor(payload.conversationId).settle(message.id);
|
|
2685
2936
|
settleInboundMessageId(message.id, true);
|
|
2686
2937
|
return;
|
|
2687
2938
|
}
|
|
@@ -2706,9 +2957,13 @@ export async function main() {
|
|
|
2706
2957
|
onConversationUpdated: (payload) => {
|
|
2707
2958
|
handleConversationUpdated(payload);
|
|
2708
2959
|
},
|
|
2960
|
+
onContactRequestUpdated: (payload) => {
|
|
2961
|
+
recordContactLifecycleEvent(payload);
|
|
2962
|
+
},
|
|
2709
2963
|
onConnected: () => {
|
|
2710
2964
|
streamConnected = true;
|
|
2711
2965
|
void publishRuntimeHeartbeat();
|
|
2966
|
+
observeReconnectRecovery('reconnect', reconnectRecovery.onConnected());
|
|
2712
2967
|
console.error('[canon-codex] SSE connected');
|
|
2713
2968
|
},
|
|
2714
2969
|
onDisconnected: () => {
|
|
@@ -2716,6 +2971,7 @@ export async function main() {
|
|
|
2716
2971
|
runtimeState.clearAgentRuntime().catch(() => { });
|
|
2717
2972
|
console.error('[canon-codex] SSE disconnected');
|
|
2718
2973
|
},
|
|
2974
|
+
onReplayExpired: () => observeReconnectRecovery('replay-expired', reconnectRecovery.onReplayExpired()),
|
|
2719
2975
|
onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
|
|
2720
2976
|
},
|
|
2721
2977
|
});
|
|
@@ -2734,57 +2990,10 @@ export async function main() {
|
|
|
2734
2990
|
catch (error) {
|
|
2735
2991
|
console.error('[canon-codex] Failed to load startup conversations:', error);
|
|
2736
2992
|
}
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
baseCwd: workingDir,
|
|
2742
|
-
})?.lastInboundMessageId ?? null;
|
|
2743
|
-
const recovered = await collectMissedInboundMessages({
|
|
2744
|
-
fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
2745
|
-
cursor,
|
|
2746
|
-
agentId,
|
|
2747
|
-
});
|
|
2748
|
-
if (recovered.mode === 'truncated-window') {
|
|
2749
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
|
|
2750
|
-
}
|
|
2751
|
-
for (const message of recovered.messages) {
|
|
2752
|
-
const isPlanReply = message.metadata?.type === 'plan_approval_reply';
|
|
2753
|
-
if (!isPlanReply && !shouldTriggerAgentTurn({
|
|
2754
|
-
senderType: message.senderType,
|
|
2755
|
-
metadata: message.metadata,
|
|
2756
|
-
}).allow) {
|
|
2757
|
-
persistInboundRecoveryCursorWhenIdle(conversationId, message.id);
|
|
2758
|
-
continue;
|
|
2759
|
-
}
|
|
2760
|
-
if (!claimInboundMessageId(message.id))
|
|
2761
|
-
continue;
|
|
2762
|
-
try {
|
|
2763
|
-
await enqueueInboundMessage({
|
|
2764
|
-
conversationId,
|
|
2765
|
-
message,
|
|
2766
|
-
senderName: message.senderName || message.senderId,
|
|
2767
|
-
isOwner: message.senderId === ownerId,
|
|
2768
|
-
behavior: recovered.newestPage.behavior,
|
|
2769
|
-
activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
|
|
2770
|
-
selfContexts: recovered.newestPage.selfContexts,
|
|
2771
|
-
hydratedPage: recovered.newestPage,
|
|
2772
|
-
});
|
|
2773
|
-
settleInboundMessageId(message.id, true);
|
|
2774
|
-
}
|
|
2775
|
-
catch (error) {
|
|
2776
|
-
settleInboundMessageId(message.id, false);
|
|
2777
|
-
throw error;
|
|
2778
|
-
}
|
|
2779
|
-
}
|
|
2780
|
-
if (recovered.messages.length > 0) {
|
|
2781
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
|
|
2782
|
-
}
|
|
2783
|
-
}
|
|
2784
|
-
catch (error) {
|
|
2785
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
|
|
2786
|
-
}
|
|
2787
|
-
}
|
|
2993
|
+
await reconnectRecovery.recoverNow().catch((error) => {
|
|
2994
|
+
console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
|
|
2995
|
+
});
|
|
2996
|
+
startupRecoveryComplete = true;
|
|
2788
2997
|
startCodexStreamInBackground(stream, (error) => {
|
|
2789
2998
|
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
2790
2999
|
});
|
package/dist/register.js
CHANGED
|
@@ -28,6 +28,7 @@ After approval, start it with CANON_AGENT=<profile> canon-codex --cwd /path/to/p
|
|
|
28
28
|
const OPTIONS = {
|
|
29
29
|
moduleUrl: import.meta.url,
|
|
30
30
|
clientType: 'codex',
|
|
31
|
+
sessionSetupPolicy: 'runtime_descriptor_required',
|
|
31
32
|
cliName: 'canon-codex-register',
|
|
32
33
|
hostBinName: 'canon-codex',
|
|
33
34
|
developerInfo: 'Codex host plugin',
|
package/dist/session-store.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export declare function buildCodexThreadPolicyFingerprint(input: {
|
|
|
3
3
|
baseCwd: string;
|
|
4
4
|
executionMode?: ExecutionEnvironmentMode;
|
|
5
5
|
permissionMode?: string | null;
|
|
6
|
+
model?: string | null;
|
|
7
|
+
serviceAgentMode?: boolean;
|
|
6
8
|
sandbox?: string | null;
|
|
7
9
|
approvalPolicy?: string | null;
|
|
8
10
|
fullAuto?: boolean;
|
package/dist/session-store.js
CHANGED
|
@@ -6,6 +6,14 @@ export function buildCodexThreadPolicyFingerprint(input) {
|
|
|
6
6
|
baseCwd: input.baseCwd,
|
|
7
7
|
executionMode: input.executionMode ?? null,
|
|
8
8
|
permissionMode: input.permissionMode ?? null,
|
|
9
|
+
// Service agents make the model a host-owned runtime choice. Include it,
|
|
10
|
+
// plus an explicit service-mode marker, so the first service-agent
|
|
11
|
+
// release drops coding threads that may retain a member-selected model
|
|
12
|
+
// and later host model changes start a clean thread. Ordinary Codex host
|
|
13
|
+
// fingerprints intentionally remain byte-for-byte compatible.
|
|
14
|
+
...(input.serviceAgentMode
|
|
15
|
+
? { serviceAgentMode: true, model: input.model ?? null }
|
|
16
|
+
: {}),
|
|
9
17
|
sandbox: input.sandbox ?? null,
|
|
10
18
|
approvalPolicy: input.approvalPolicy ?? null,
|
|
11
19
|
fullAuto: input.fullAuto === true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
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 ../rich-cards",
|
|
24
|
+
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../agent-tools ../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.
|
|
33
|
-
"@canonmsg/
|
|
34
|
-
"@canonmsg/
|
|
32
|
+
"@canonmsg/agent-sdk": "^8.9.0",
|
|
33
|
+
"@canonmsg/agent-tools": "^0.6.0",
|
|
34
|
+
"@canonmsg/coding-agent-host": "^0.6.0",
|
|
35
|
+
"@canonmsg/core": "^10.7.0",
|
|
35
36
|
"@canonmsg/rich-cards": "^0.10.0"
|
|
36
37
|
},
|
|
37
38
|
"engines": {
|