@canonmsg/codex-plugin 0.26.2 → 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 +17 -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 +3 -1
- package/dist/host.js +236 -104
- package/dist/register.js +1 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -109,6 +109,23 @@ discovery it also offers no model picker and a fixed effort list. It registers
|
|
|
109
109
|
no dynamic tools either, so `no_reply` is unavailable there, the same way rich
|
|
110
110
|
cards are.
|
|
111
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
|
+
|
|
112
129
|
Set `CANON_CODEX_TRANSPORT=exec` or `CANON_CODEX_TRANSPORT=app-server` to skip
|
|
113
130
|
the probe when it misfires.
|
|
114
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';
|
|
@@ -49,6 +49,7 @@ export declare function buildCodexServiceSnapshotConfig(input: {
|
|
|
49
49
|
executionMode: "locked";
|
|
50
50
|
executionBranch: null;
|
|
51
51
|
};
|
|
52
|
+
export declare function createCodexRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
|
|
52
53
|
/** Conservative fallback used only when native app-server discovery is unavailable. */
|
|
53
54
|
export declare const CODEX_EFFORT_OPTIONS: readonly CodexControlOption[];
|
|
54
55
|
export declare const CODEX_SESSION_CONFIG_FIELDS: readonly ["permissionMode", "effort"];
|
|
@@ -69,6 +70,7 @@ export declare function buildCodexRuntimeDescriptor(input: {
|
|
|
69
70
|
supportsPlanMode: boolean;
|
|
70
71
|
supportsCompact?: boolean;
|
|
71
72
|
supportsRichCards?: boolean;
|
|
73
|
+
supportsCanonCommunicationTools?: boolean;
|
|
72
74
|
skills?: ReadonlyArray<CodexSkillMetadata>;
|
|
73
75
|
serviceAgentMode?: boolean;
|
|
74
76
|
}): CanonRuntimeDescriptor;
|
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';
|
|
@@ -93,6 +93,9 @@ export function buildCodexServiceSnapshotConfig(input) {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
const MAX_SESSIONS = 12;
|
|
96
|
+
export function createCodexRecoveryCheckpointTracker(persist) {
|
|
97
|
+
return createRecoveryCheckpointTracker(persist);
|
|
98
|
+
}
|
|
96
99
|
// IDLE_TIMEOUT_MS (30 minutes) is shared with the other coding-agent hosts.
|
|
97
100
|
const HEARTBEAT_MS = 30_000;
|
|
98
101
|
/** How Codex says it finished the work but Canon would not take the answer. */
|
|
@@ -207,6 +210,9 @@ export function buildCodexRuntimeDescriptor(input) {
|
|
|
207
210
|
effortLiveBehavior: 'next_turn',
|
|
208
211
|
presentation: input.presentation,
|
|
209
212
|
streamingTextMode: 'snapshot',
|
|
213
|
+
admissionActions: input.supportsCanonCommunicationTools && !serviceAgentMode
|
|
214
|
+
? { ...HOST_ADMISSION_ACTIONS_DISABLED, requestContact: true, reachOut: true }
|
|
215
|
+
: HOST_ADMISSION_ACTIONS_DISABLED,
|
|
210
216
|
...(input.supportsPlanMode && !serviceAgentMode
|
|
211
217
|
? {
|
|
212
218
|
turnModes: [
|
|
@@ -278,12 +284,13 @@ export function getCodexRequestingUserId(message) {
|
|
|
278
284
|
async function publishAgentRuntime(agentId, runtime, rtdb) {
|
|
279
285
|
await publishHostAgentRuntime(agentId, 'codex', runtime, rtdb);
|
|
280
286
|
}
|
|
281
|
-
async function loadSessionConfig(conversationId, agentId, rtdb) {
|
|
287
|
+
async function loadSessionConfig(conversationId, agentId, rtdb, retryMissing = true) {
|
|
282
288
|
return loadHostSessionConfig({
|
|
283
289
|
conversationId,
|
|
284
290
|
agentId,
|
|
285
291
|
rtdb,
|
|
286
292
|
extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
|
|
293
|
+
retryMissingMs: retryMissing ? 3_000 : 0,
|
|
287
294
|
});
|
|
288
295
|
}
|
|
289
296
|
export function resolveSessionExecutionMode(config, serviceAgentMode = false) {
|
|
@@ -370,8 +377,8 @@ function buildCanonPrompt(input) {
|
|
|
370
377
|
message: input.message,
|
|
371
378
|
})), input.noReplyToolName ? { noReplyToolName: input.noReplyToolName } : {});
|
|
372
379
|
}
|
|
373
|
-
function renderInboundContent(message, materialized) {
|
|
374
|
-
return renderCanonHostInboundContent(message, materialized);
|
|
380
|
+
function renderInboundContent(message, materialized, options) {
|
|
381
|
+
return renderCanonHostInboundContent(message, materialized, options);
|
|
375
382
|
}
|
|
376
383
|
async function materializePromptReplyContext(input) {
|
|
377
384
|
if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
|
|
@@ -388,6 +395,16 @@ async function materializePromptReplyContext(input) {
|
|
|
388
395
|
return { replyContext: input.replyContext, materialized: [] };
|
|
389
396
|
}
|
|
390
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
|
+
}
|
|
391
408
|
function summarizeCommand(command) {
|
|
392
409
|
const trimmed = command.trim();
|
|
393
410
|
if (!trimmed)
|
|
@@ -816,7 +833,15 @@ export async function main() {
|
|
|
816
833
|
});
|
|
817
834
|
const sessions = new Map();
|
|
818
835
|
const pendingSessionCreations = new Map();
|
|
819
|
-
|
|
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
|
+
}
|
|
820
845
|
const conversationCache = new Map();
|
|
821
846
|
const knownConversationIds = new Set();
|
|
822
847
|
const promptedGroupContextConversationIds = new Set();
|
|
@@ -902,6 +927,7 @@ export async function main() {
|
|
|
902
927
|
ownerName,
|
|
903
928
|
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
904
929
|
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
930
|
+
renderOptions: input.renderOptions,
|
|
905
931
|
});
|
|
906
932
|
}
|
|
907
933
|
function writeState(session) {
|
|
@@ -964,27 +990,20 @@ export async function main() {
|
|
|
964
990
|
}
|
|
965
991
|
function persistInboundRecoveryCursor(conversationId, messageId) {
|
|
966
992
|
if (!messageId)
|
|
967
|
-
return;
|
|
993
|
+
return true;
|
|
968
994
|
try {
|
|
969
995
|
saveRuntimeSessionState(runtimeId, {
|
|
970
996
|
conversationId,
|
|
971
997
|
baseCwd: workingDir,
|
|
972
998
|
lastInboundMessageId: messageId,
|
|
973
999
|
});
|
|
1000
|
+
return true;
|
|
974
1001
|
}
|
|
975
1002
|
catch (error) {
|
|
976
1003
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
|
|
1004
|
+
return false;
|
|
977
1005
|
}
|
|
978
1006
|
}
|
|
979
|
-
function persistInboundRecoveryCursorWhenIdle(conversationId, messageId) {
|
|
980
|
-
const session = sessions.get(conversationId);
|
|
981
|
-
// A later queued turn will advance past this non-triggering message. Do
|
|
982
|
-
// not write it ahead of earlier work and then let that work regress the
|
|
983
|
-
// cursor when it completes.
|
|
984
|
-
if (session?.running || (session?.queue.length ?? 0) > 0)
|
|
985
|
-
return;
|
|
986
|
-
persistInboundRecoveryCursor(conversationId, messageId);
|
|
987
|
-
}
|
|
988
1007
|
async function markQueuedPromptsRejected(conversationId, prompts) {
|
|
989
1008
|
await Promise.all(prompts.map((prompt) => {
|
|
990
1009
|
if (!prompt.markAccepted || !prompt.sourceMessageId)
|
|
@@ -992,27 +1011,21 @@ export async function main() {
|
|
|
992
1011
|
return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
|
|
993
1012
|
}));
|
|
994
1013
|
}
|
|
995
|
-
function
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
if (!latest)
|
|
1000
|
-
return;
|
|
1001
|
-
if (session.running) {
|
|
1002
|
-
session.pendingDroppedRecoveryCursor = latest;
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1005
|
-
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);
|
|
1006
1018
|
}
|
|
1007
1019
|
function removeQueuedPrompt(conversationId, sourceMessageId) {
|
|
1008
1020
|
const session = sessions.get(conversationId);
|
|
1009
1021
|
if (!session || session.queue.length === 0)
|
|
1010
1022
|
return;
|
|
1011
|
-
const
|
|
1023
|
+
const removed = session.queue.filter((prompt) => prompt.sourceMessageId === sourceMessageId);
|
|
1024
|
+
if (removed.length === 0)
|
|
1025
|
+
return;
|
|
1012
1026
|
session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
}
|
|
1027
|
+
settleRejectedPromptCheckpoints(conversationId, removed);
|
|
1028
|
+
writeTurn(session);
|
|
1016
1029
|
}
|
|
1017
1030
|
function clearStreaming(conversationId) {
|
|
1018
1031
|
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
@@ -1196,7 +1209,7 @@ export async function main() {
|
|
|
1196
1209
|
session.resetRequested = true;
|
|
1197
1210
|
const droppedPrompts = session.queue.splice(0);
|
|
1198
1211
|
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
1199
|
-
|
|
1212
|
+
settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
|
|
1200
1213
|
clearStoredThreadId(runtimeId, conversationId, session.environment.baseCwd, session.environment.mode);
|
|
1201
1214
|
session.adapter.clearThreadId();
|
|
1202
1215
|
session.activeSelfContextId = null;
|
|
@@ -1212,6 +1225,7 @@ export async function main() {
|
|
|
1212
1225
|
session.currentTurnOpenedAt = null;
|
|
1213
1226
|
session.currentTurnUpdatedAt = null;
|
|
1214
1227
|
session.currentTurnCanUseCodexAppTools = false;
|
|
1228
|
+
session.currentTurnCanUseCodexCanonTools = false;
|
|
1215
1229
|
session.currentTurnSilenced = false;
|
|
1216
1230
|
session.lastAcceptedIntent = null;
|
|
1217
1231
|
session.resetRequested = false;
|
|
@@ -1249,7 +1263,7 @@ export async function main() {
|
|
|
1249
1263
|
evictOldestIdle();
|
|
1250
1264
|
}
|
|
1251
1265
|
const creation = (async () => {
|
|
1252
|
-
const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb), serviceAgentMode);
|
|
1266
|
+
const config = resolveCodexSessionConfig(await loadSessionConfig(conversationId, agentId, rtdb, !serviceAgentMode), serviceAgentMode);
|
|
1253
1267
|
const sessionExecutionMode = resolveSessionExecutionMode(config, serviceAgentMode);
|
|
1254
1268
|
const workspaceCwd = resolveWorkspaceCwd(config, serviceAgentMode);
|
|
1255
1269
|
const environment = prepareConversationEnvironment({
|
|
@@ -1324,6 +1338,8 @@ export async function main() {
|
|
|
1324
1338
|
currentTurnOpenedAt: null,
|
|
1325
1339
|
currentTurnUpdatedAt: null,
|
|
1326
1340
|
currentTurnCanUseCodexAppTools: false,
|
|
1341
|
+
currentTurnCanUseCodexCanonTools: false,
|
|
1342
|
+
currentTurnReplyContactTarget: null,
|
|
1327
1343
|
currentTurnAbortController: null,
|
|
1328
1344
|
// Corrected by the first turn that runs; a session with no turn
|
|
1329
1345
|
// publishes nothing anyway, and quiet is never an accident.
|
|
@@ -1331,7 +1347,6 @@ export async function main() {
|
|
|
1331
1347
|
currentTurnSilenced: false,
|
|
1332
1348
|
activeSelfContextId: null,
|
|
1333
1349
|
lastAcceptedIntent: null,
|
|
1334
|
-
pendingDroppedRecoveryCursor: null,
|
|
1335
1350
|
resetRequested: false,
|
|
1336
1351
|
lastActivity: Date.now(),
|
|
1337
1352
|
typingKeepaliveTimer: null,
|
|
@@ -1371,9 +1386,10 @@ export async function main() {
|
|
|
1371
1386
|
planMode,
|
|
1372
1387
|
artifactRoutingMode: turn.artifactRoutingMode ?? 'disabled',
|
|
1373
1388
|
canUseCodexAppTools: turn.canUseCodexAppTools ?? false,
|
|
1389
|
+
canUseCodexCanonTools: turn.canUseCodexCanonTools ?? false,
|
|
1374
1390
|
...(turn.turnVerbosity ? { turnVerbosity: turn.turnVerbosity } : {}),
|
|
1375
1391
|
requestingUserId: turn.requestingUserId ?? null,
|
|
1376
|
-
|
|
1392
|
+
...(turn.replyContactTarget ? { replyContactTarget: turn.replyContactTarget } : {}),
|
|
1377
1393
|
};
|
|
1378
1394
|
if (toFront) {
|
|
1379
1395
|
session.queue.unshift(nextPrompt);
|
|
@@ -1404,6 +1420,38 @@ export async function main() {
|
|
|
1404
1420
|
requestingUserId: responseUserId,
|
|
1405
1421
|
});
|
|
1406
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
|
+
}
|
|
1407
1455
|
function resolveArtifactRoutingMode(participantContext) {
|
|
1408
1456
|
return participantContext.conversationType === 'direct' && participantContext.isOwner
|
|
1409
1457
|
? 'workspace-generated'
|
|
@@ -1415,15 +1463,19 @@ export async function main() {
|
|
|
1415
1463
|
* so a prompt that waits behind another still runs under the answer it
|
|
1416
1464
|
* arrived with.
|
|
1417
1465
|
*/
|
|
1418
|
-
function resolveCodexTurnModes(participantContext, message) {
|
|
1466
|
+
function resolveCodexTurnModes(participantContext, message, replyContext) {
|
|
1419
1467
|
return {
|
|
1420
1468
|
artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
|
|
1421
1469
|
canUseCodexAppTools: participantContext.isOwner || serviceAgentMode,
|
|
1470
|
+
canUseCodexCanonTools: participantContext.isOwner && !serviceAgentMode,
|
|
1422
1471
|
turnVerbosity: resolveTurnVerbosity({
|
|
1423
1472
|
configured: configuredTurnVerbosity,
|
|
1424
1473
|
conversationType: participantContext.conversationType,
|
|
1425
1474
|
}),
|
|
1426
1475
|
requestingUserId: getCodexRequestingUserId(message),
|
|
1476
|
+
...(participantContext.isOwner && replyContext
|
|
1477
|
+
? { replyContactTarget: ownerBoundReplyContactTarget(replyContext) }
|
|
1478
|
+
: {}),
|
|
1427
1479
|
};
|
|
1428
1480
|
}
|
|
1429
1481
|
function runtimeCardRequestPayload(method, params) {
|
|
@@ -1465,6 +1517,23 @@ export async function main() {
|
|
|
1465
1517
|
if (disposition === 'denied-non-owner') {
|
|
1466
1518
|
return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
|
|
1467
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
|
+
}
|
|
1468
1537
|
if (isCanonRuntimeControlToolCall(params)) {
|
|
1469
1538
|
const command = parseCanonRuntimeControlRequest(params);
|
|
1470
1539
|
if (!command) {
|
|
@@ -1759,7 +1828,7 @@ export async function main() {
|
|
|
1759
1828
|
knownConversationIds.add(input.conversationId);
|
|
1760
1829
|
if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
|
|
1761
1830
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
|
|
1762
|
-
|
|
1831
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1763
1832
|
return;
|
|
1764
1833
|
}
|
|
1765
1834
|
if (input.message.metadata?.type === 'plan_approval_reply') {
|
|
@@ -1767,7 +1836,7 @@ export async function main() {
|
|
|
1767
1836
|
// A service agent never advertises or enters plan mode. Consume stale
|
|
1768
1837
|
// replies left by an older coding descriptor instead of turning them
|
|
1769
1838
|
// into hidden plan/implementation prompts after an upgrade or restart.
|
|
1770
|
-
|
|
1839
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1771
1840
|
return;
|
|
1772
1841
|
}
|
|
1773
1842
|
const planId = readString(input.message.metadata, 'planId');
|
|
@@ -1775,7 +1844,7 @@ export async function main() {
|
|
|
1775
1844
|
senderId: input.message.senderId,
|
|
1776
1845
|
metadata: input.message.metadata,
|
|
1777
1846
|
})) {
|
|
1778
|
-
|
|
1847
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1779
1848
|
return;
|
|
1780
1849
|
}
|
|
1781
1850
|
const session = await getOrCreateSession(input.conversationId);
|
|
@@ -1804,7 +1873,8 @@ export async function main() {
|
|
|
1804
1873
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
1805
1874
|
}
|
|
1806
1875
|
}
|
|
1807
|
-
const
|
|
1876
|
+
const renderOptions = resolveInboundContentRenderOptions(input.isOwner);
|
|
1877
|
+
const renderedContent = renderInboundContent(input.message, materialized, renderOptions);
|
|
1808
1878
|
const turnMetadata = normalizeTurnMetadata(input.message.metadata);
|
|
1809
1879
|
const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
|
|
1810
1880
|
const planCommand = resolveCodexPlanCommand({
|
|
@@ -1823,6 +1893,7 @@ export async function main() {
|
|
|
1823
1893
|
selfContexts: input.selfContexts,
|
|
1824
1894
|
provenance: input.provenance,
|
|
1825
1895
|
hydratedPage: input.hydratedPage,
|
|
1896
|
+
renderOptions,
|
|
1826
1897
|
});
|
|
1827
1898
|
const behavior = input.behavior ?? hydrated.behavior;
|
|
1828
1899
|
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
@@ -1849,7 +1920,7 @@ export async function main() {
|
|
|
1849
1920
|
: decideAutoReply(participantContext, behavior);
|
|
1850
1921
|
if (!autoReply.allow) {
|
|
1851
1922
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
1852
|
-
|
|
1923
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1853
1924
|
return;
|
|
1854
1925
|
}
|
|
1855
1926
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
@@ -1873,11 +1944,11 @@ export async function main() {
|
|
|
1873
1944
|
replyBehavior: 'suppress_auto_reply',
|
|
1874
1945
|
},
|
|
1875
1946
|
}).catch(() => { });
|
|
1876
|
-
|
|
1947
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1877
1948
|
return;
|
|
1878
1949
|
}
|
|
1879
1950
|
session.activeSelfContextId = activeSelfContextId;
|
|
1880
|
-
const
|
|
1951
|
+
const basePrompt = buildCanonPrompt({
|
|
1881
1952
|
content,
|
|
1882
1953
|
conversationId: input.conversationId,
|
|
1883
1954
|
participantContext,
|
|
@@ -1889,8 +1960,12 @@ export async function main() {
|
|
|
1889
1960
|
message: input.message,
|
|
1890
1961
|
...(useAppServer ? { noReplyToolName: CODEX_NO_REPLY_MODEL_TOOL_NAME } : {}),
|
|
1891
1962
|
});
|
|
1963
|
+
const lifecycleContext = input.isOwner
|
|
1964
|
+
? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, input.conversationId))
|
|
1965
|
+
: null;
|
|
1966
|
+
const prompt = lifecycleContext ? `${basePrompt}\n\n${lifecycleContext}` : basePrompt;
|
|
1892
1967
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
1893
|
-
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));
|
|
1894
1969
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
1895
1970
|
session.currentTurnAbortController?.abort(new Error('Codex turn interrupted by a newer message'));
|
|
1896
1971
|
await session.adapter.interrupt().catch(() => { });
|
|
@@ -1898,7 +1973,7 @@ export async function main() {
|
|
|
1898
1973
|
typingSignals.clear(input.conversationId).catch(() => { });
|
|
1899
1974
|
return;
|
|
1900
1975
|
}
|
|
1901
|
-
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));
|
|
1902
1977
|
}
|
|
1903
1978
|
function sendTurnArtifactFile(session, file) {
|
|
1904
1979
|
return sendMediaFileMessage(client, session.conversationId, file.path, '', {
|
|
@@ -1929,6 +2004,8 @@ export async function main() {
|
|
|
1929
2004
|
session.currentTurnOpenedAt = Date.now();
|
|
1930
2005
|
session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
|
|
1931
2006
|
session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
|
|
2007
|
+
session.currentTurnCanUseCodexCanonTools = nextTurn.canUseCodexCanonTools === true;
|
|
2008
|
+
session.currentTurnReplyContactTarget = nextTurn.replyContactTarget ?? null;
|
|
1932
2009
|
session.currentTurnAbortController = new AbortController();
|
|
1933
2010
|
// A continuation prompt (a plan-review result) carries none, and keeps the
|
|
1934
2011
|
// conversation's last answer rather than silently reverting to verbose.
|
|
@@ -2333,11 +2410,7 @@ export async function main() {
|
|
|
2333
2410
|
finally {
|
|
2334
2411
|
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2335
2412
|
session.currentTurnAbortController = null;
|
|
2336
|
-
|
|
2337
|
-
if (session.pendingDroppedRecoveryCursor) {
|
|
2338
|
-
persistInboundRecoveryCursor(session.conversationId, session.pendingDroppedRecoveryCursor);
|
|
2339
|
-
session.pendingDroppedRecoveryCursor = null;
|
|
2340
|
-
}
|
|
2413
|
+
recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
|
|
2341
2414
|
stopVisibleWorkSignal(session);
|
|
2342
2415
|
session.running = false;
|
|
2343
2416
|
session.state.state = 'idle';
|
|
@@ -2346,6 +2419,8 @@ export async function main() {
|
|
|
2346
2419
|
session.currentTurnOpenedAt = null;
|
|
2347
2420
|
session.currentTurnUpdatedAt = null;
|
|
2348
2421
|
session.currentTurnCanUseCodexAppTools = false;
|
|
2422
|
+
session.currentTurnCanUseCodexCanonTools = false;
|
|
2423
|
+
session.currentTurnReplyContactTarget = null;
|
|
2349
2424
|
session.currentTurnSilenced = false;
|
|
2350
2425
|
session.lastAcceptedIntent = null;
|
|
2351
2426
|
session.resetRequested = false;
|
|
@@ -2429,11 +2504,25 @@ export async function main() {
|
|
|
2429
2504
|
supportsPlanMode: useAppServer,
|
|
2430
2505
|
supportsCompact: useAppServer,
|
|
2431
2506
|
supportsRichCards: useAppServer,
|
|
2507
|
+
supportsCanonCommunicationTools: useAppServer,
|
|
2432
2508
|
skills: codexSkills,
|
|
2433
2509
|
serviceAgentMode,
|
|
2434
2510
|
}),
|
|
2435
2511
|
});
|
|
2436
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
|
+
};
|
|
2437
2526
|
async function refreshCodexSkillInventory(forceReload = false) {
|
|
2438
2527
|
if (!useAppServer)
|
|
2439
2528
|
return;
|
|
@@ -2550,7 +2639,7 @@ export async function main() {
|
|
|
2550
2639
|
if (type === 'stop_and_drop') {
|
|
2551
2640
|
const droppedPrompts = session.queue.splice(0);
|
|
2552
2641
|
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
2553
|
-
|
|
2642
|
+
settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
|
|
2554
2643
|
}
|
|
2555
2644
|
if (session.running) {
|
|
2556
2645
|
session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
|
|
@@ -2745,6 +2834,90 @@ export async function main() {
|
|
|
2745
2834
|
publishRuntimeDetailsInFlight = false;
|
|
2746
2835
|
}
|
|
2747
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
|
+
};
|
|
2748
2921
|
const stream = new CanonStream({
|
|
2749
2922
|
apiKey,
|
|
2750
2923
|
agentId,
|
|
@@ -2756,9 +2929,10 @@ export async function main() {
|
|
|
2756
2929
|
return;
|
|
2757
2930
|
if (!claimInboundMessageId(message.id))
|
|
2758
2931
|
return;
|
|
2932
|
+
recoveryCheckpointsFor(payload.conversationId).track(message.id);
|
|
2759
2933
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2760
2934
|
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2761
|
-
|
|
2935
|
+
recoveryCheckpointsFor(payload.conversationId).settle(message.id);
|
|
2762
2936
|
settleInboundMessageId(message.id, true);
|
|
2763
2937
|
return;
|
|
2764
2938
|
}
|
|
@@ -2783,9 +2957,13 @@ export async function main() {
|
|
|
2783
2957
|
onConversationUpdated: (payload) => {
|
|
2784
2958
|
handleConversationUpdated(payload);
|
|
2785
2959
|
},
|
|
2960
|
+
onContactRequestUpdated: (payload) => {
|
|
2961
|
+
recordContactLifecycleEvent(payload);
|
|
2962
|
+
},
|
|
2786
2963
|
onConnected: () => {
|
|
2787
2964
|
streamConnected = true;
|
|
2788
2965
|
void publishRuntimeHeartbeat();
|
|
2966
|
+
observeReconnectRecovery('reconnect', reconnectRecovery.onConnected());
|
|
2789
2967
|
console.error('[canon-codex] SSE connected');
|
|
2790
2968
|
},
|
|
2791
2969
|
onDisconnected: () => {
|
|
@@ -2793,6 +2971,7 @@ export async function main() {
|
|
|
2793
2971
|
runtimeState.clearAgentRuntime().catch(() => { });
|
|
2794
2972
|
console.error('[canon-codex] SSE disconnected');
|
|
2795
2973
|
},
|
|
2974
|
+
onReplayExpired: () => observeReconnectRecovery('replay-expired', reconnectRecovery.onReplayExpired()),
|
|
2796
2975
|
onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
|
|
2797
2976
|
},
|
|
2798
2977
|
});
|
|
@@ -2811,57 +2990,10 @@ export async function main() {
|
|
|
2811
2990
|
catch (error) {
|
|
2812
2991
|
console.error('[canon-codex] Failed to load startup conversations:', error);
|
|
2813
2992
|
}
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
baseCwd: workingDir,
|
|
2819
|
-
})?.lastInboundMessageId ?? null;
|
|
2820
|
-
const recovered = await collectMissedInboundMessages({
|
|
2821
|
-
fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
2822
|
-
cursor,
|
|
2823
|
-
agentId,
|
|
2824
|
-
});
|
|
2825
|
-
if (recovered.mode === 'truncated-window') {
|
|
2826
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
|
|
2827
|
-
}
|
|
2828
|
-
for (const message of recovered.messages) {
|
|
2829
|
-
const isPlanReply = isCodexPlanApprovalReply(message.metadata, serviceAgentMode);
|
|
2830
|
-
if (!isPlanReply && !shouldTriggerAgentTurn({
|
|
2831
|
-
senderType: message.senderType,
|
|
2832
|
-
metadata: message.metadata,
|
|
2833
|
-
}).allow) {
|
|
2834
|
-
persistInboundRecoveryCursorWhenIdle(conversationId, message.id);
|
|
2835
|
-
continue;
|
|
2836
|
-
}
|
|
2837
|
-
if (!claimInboundMessageId(message.id))
|
|
2838
|
-
continue;
|
|
2839
|
-
try {
|
|
2840
|
-
await enqueueInboundMessage({
|
|
2841
|
-
conversationId,
|
|
2842
|
-
message,
|
|
2843
|
-
senderName: message.senderName || message.senderId,
|
|
2844
|
-
isOwner: message.senderId === ownerId,
|
|
2845
|
-
behavior: recovered.newestPage.behavior,
|
|
2846
|
-
activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
|
|
2847
|
-
selfContexts: recovered.newestPage.selfContexts,
|
|
2848
|
-
hydratedPage: recovered.newestPage,
|
|
2849
|
-
});
|
|
2850
|
-
settleInboundMessageId(message.id, true);
|
|
2851
|
-
}
|
|
2852
|
-
catch (error) {
|
|
2853
|
-
settleInboundMessageId(message.id, false);
|
|
2854
|
-
throw error;
|
|
2855
|
-
}
|
|
2856
|
-
}
|
|
2857
|
-
if (recovered.messages.length > 0) {
|
|
2858
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
|
|
2859
|
-
}
|
|
2860
|
-
}
|
|
2861
|
-
catch (error) {
|
|
2862
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
|
|
2863
|
-
}
|
|
2864
|
-
}
|
|
2993
|
+
await reconnectRecovery.recoverNow().catch((error) => {
|
|
2994
|
+
console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
|
|
2995
|
+
});
|
|
2996
|
+
startupRecoveryComplete = true;
|
|
2865
2997
|
startCodexStreamInBackground(stream, (error) => {
|
|
2866
2998
|
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
2867
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/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": {
|