@canonmsg/codex-plugin 0.32.2 → 0.32.5
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 +4 -0
- package/dist/adapter.d.ts +4 -0
- package/dist/app-server-adapter.d.ts +7 -0
- package/dist/app-server-adapter.js +78 -3
- package/dist/host.d.ts +3 -0
- package/dist/host.js +205 -87
- package/dist/model-catalog.js +9 -5
- package/dist/runtime-status.d.ts +16 -0
- package/dist/runtime-status.js +93 -0
- package/package.json +6 -5
- package/scripts/smoke-protocol.mjs +33 -0
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Connect the local Codex CLI to [Canon](https://canonmail.com/agents) so a Canon user can message your coding agent from the app.
|
|
4
4
|
|
|
5
|
+
The audited stable app-server baseline is **Codex CLI 0.155.1**. Install it with `npm install -g @openai/codex@0.155.1`. Canon keeps using the native CLI installed on the host; it does not replace your CLI automatically. Release checks run `npm run smoke:protocol --workspace @canonmsg/codex-plugin` against this version using a temporary, unauthenticated home without starting a model turn.
|
|
6
|
+
|
|
7
|
+
Managed app-server sessions withdraw Canon questions and approvals when Codex resolves them or their turn ends. Model choices include reported input modalities. Runtime details show observed provider authentication, usage-limit availability and tool reconnect needs without exposing account identifiers, tool credentials or raw provider errors.
|
|
8
|
+
|
|
5
9
|
The plugin uses the local user's existing Codex authentication by default. That means Canon follows whatever plan or login mode the user has configured in Codex itself, instead of asking for a separate Canon-side OpenAI credential.
|
|
6
10
|
|
|
7
11
|
## Quick start
|
package/dist/adapter.d.ts
CHANGED
|
@@ -38,11 +38,15 @@ export type CodexEvent = {
|
|
|
38
38
|
effort: string | null;
|
|
39
39
|
} | {
|
|
40
40
|
type: 'skills.changed';
|
|
41
|
+
} | {
|
|
42
|
+
type: 'request.resolved';
|
|
41
43
|
};
|
|
42
44
|
export interface CodexServerRequest {
|
|
43
45
|
id: string | number;
|
|
44
46
|
method: string;
|
|
45
47
|
params: Record<string, unknown>;
|
|
48
|
+
/** Aborted when the native request is resolved or its owning turn ends. */
|
|
49
|
+
signal?: AbortSignal;
|
|
46
50
|
}
|
|
47
51
|
export interface CodexRunTurnOptions {
|
|
48
52
|
/** Original human text, before Canon adds conversation/reply context. */
|
|
@@ -18,6 +18,7 @@ export interface CodexModelMetadata {
|
|
|
18
18
|
displayName: string;
|
|
19
19
|
description?: string;
|
|
20
20
|
supportedReasoningEfforts: CodexReasoningEffortMetadata[];
|
|
21
|
+
inputModalities?: string[];
|
|
21
22
|
defaultReasoningEffort?: string;
|
|
22
23
|
isDefault: boolean;
|
|
23
24
|
}
|
|
@@ -32,6 +33,7 @@ export declare class CodexAppServerAdapter {
|
|
|
32
33
|
private readonly fullAuto;
|
|
33
34
|
private readonly bypassApprovalsAndSandbox;
|
|
34
35
|
private readonly dynamicTools;
|
|
36
|
+
private readonly onCapabilitiesChanged?;
|
|
35
37
|
private child;
|
|
36
38
|
private threadId;
|
|
37
39
|
private loadedThreadId;
|
|
@@ -41,6 +43,7 @@ export declare class CodexAppServerAdapter {
|
|
|
41
43
|
private currentTurnId;
|
|
42
44
|
private requestSeq;
|
|
43
45
|
private pending;
|
|
46
|
+
private serverRequests;
|
|
44
47
|
private currentOnEvent;
|
|
45
48
|
private currentOnLog;
|
|
46
49
|
private currentRequestHandler;
|
|
@@ -51,6 +54,7 @@ export declare class CodexAppServerAdapter {
|
|
|
51
54
|
private interrupted;
|
|
52
55
|
private initialized;
|
|
53
56
|
private skillsCache;
|
|
57
|
+
private readonly runtimeStatus;
|
|
54
58
|
private messageTextByItem;
|
|
55
59
|
private planText;
|
|
56
60
|
constructor(opts: {
|
|
@@ -65,6 +69,7 @@ export declare class CodexAppServerAdapter {
|
|
|
65
69
|
fullAuto?: boolean;
|
|
66
70
|
bypassApprovalsAndSandbox?: boolean;
|
|
67
71
|
dynamicTools?: readonly JsonRecord[];
|
|
72
|
+
onCapabilitiesChanged?: () => void;
|
|
68
73
|
});
|
|
69
74
|
getThreadId(): string | null;
|
|
70
75
|
getResolvedModel(): string | null;
|
|
@@ -72,6 +77,8 @@ export declare class CodexAppServerAdapter {
|
|
|
72
77
|
model?: string;
|
|
73
78
|
effort?: string;
|
|
74
79
|
}>;
|
|
80
|
+
getRuntimeFacts(): import("@canonmsg/core").CanonRuntimeFact[];
|
|
81
|
+
refreshRuntimeFacts(): Promise<void>;
|
|
75
82
|
getResolvedReasoningEffort(): string | null;
|
|
76
83
|
clearThreadId(): void;
|
|
77
84
|
setModel(model: string | null): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { normalizePlanStepStatus, renderPlanSteps } from '@canonmsg/coding-agent-host';
|
|
4
|
+
import { CodexRuntimeStatus } from './runtime-status.js';
|
|
4
5
|
const DEFAULT_DISCOVERY_REQUEST_TIMEOUT_MS = 5_000;
|
|
5
6
|
export class CodexAppServerAdapter {
|
|
6
7
|
cwd;
|
|
@@ -13,6 +14,7 @@ export class CodexAppServerAdapter {
|
|
|
13
14
|
fullAuto;
|
|
14
15
|
bypassApprovalsAndSandbox;
|
|
15
16
|
dynamicTools;
|
|
17
|
+
onCapabilitiesChanged;
|
|
16
18
|
child = null;
|
|
17
19
|
threadId;
|
|
18
20
|
loadedThreadId = null;
|
|
@@ -23,6 +25,7 @@ export class CodexAppServerAdapter {
|
|
|
23
25
|
currentTurnId = null;
|
|
24
26
|
requestSeq = 1;
|
|
25
27
|
pending = new Map();
|
|
28
|
+
serverRequests = new Map();
|
|
26
29
|
currentOnEvent = null;
|
|
27
30
|
currentOnLog = null;
|
|
28
31
|
currentRequestHandler = null;
|
|
@@ -33,6 +36,7 @@ export class CodexAppServerAdapter {
|
|
|
33
36
|
interrupted = false;
|
|
34
37
|
initialized = false;
|
|
35
38
|
skillsCache = null;
|
|
39
|
+
runtimeStatus = new CodexRuntimeStatus();
|
|
36
40
|
messageTextByItem = new Map();
|
|
37
41
|
planText = '';
|
|
38
42
|
constructor(opts) {
|
|
@@ -48,6 +52,7 @@ export class CodexAppServerAdapter {
|
|
|
48
52
|
this.fullAuto = opts.fullAuto ?? false;
|
|
49
53
|
this.bypassApprovalsAndSandbox = opts.bypassApprovalsAndSandbox ?? false;
|
|
50
54
|
this.dynamicTools = opts.dynamicTools ?? [];
|
|
55
|
+
this.onCapabilitiesChanged = opts.onCapabilitiesChanged;
|
|
51
56
|
}
|
|
52
57
|
getThreadId() {
|
|
53
58
|
return this.threadId;
|
|
@@ -58,10 +63,27 @@ export class CodexAppServerAdapter {
|
|
|
58
63
|
getObservedSettings() {
|
|
59
64
|
return { ...this.observedSettings };
|
|
60
65
|
}
|
|
66
|
+
getRuntimeFacts() {
|
|
67
|
+
return this.runtimeStatus.facts();
|
|
68
|
+
}
|
|
69
|
+
async refreshRuntimeFacts() {
|
|
70
|
+
if (!this.child || !this.initialized || !this.runtimeStatus.hasQuota())
|
|
71
|
+
return;
|
|
72
|
+
const revision = this.runtimeStatus.revision;
|
|
73
|
+
try {
|
|
74
|
+
const snapshot = await this.sendRequest('account/rateLimits/read', {}, 2_000);
|
|
75
|
+
if (revision === this.runtimeStatus.revision && isRecord(snapshot))
|
|
76
|
+
this.runtimeStatus.replaceQuota(snapshot);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Unavailable snapshots do not mean zero usage. Retain reported facts.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
61
82
|
getResolvedReasoningEffort() {
|
|
62
83
|
return this.resolvedReasoningEffort ?? this.reasoningEffort;
|
|
63
84
|
}
|
|
64
85
|
clearThreadId() {
|
|
86
|
+
this.runtimeStatus.reset();
|
|
65
87
|
this.observedSettings = {};
|
|
66
88
|
this.threadId = null;
|
|
67
89
|
this.loadedThreadId = null;
|
|
@@ -119,6 +141,7 @@ export class CodexAppServerAdapter {
|
|
|
119
141
|
}
|
|
120
142
|
close() {
|
|
121
143
|
this.observedSettings = {};
|
|
144
|
+
this.runtimeStatus.reset();
|
|
122
145
|
this.child?.kill('SIGTERM');
|
|
123
146
|
this.child = null;
|
|
124
147
|
this.initialized = false;
|
|
@@ -352,6 +375,7 @@ export class CodexAppServerAdapter {
|
|
|
352
375
|
this.observedSettings = {};
|
|
353
376
|
const message = `Codex app-server exited${code === null ? '' : ` with code ${code}`}`;
|
|
354
377
|
this.initialized = false;
|
|
378
|
+
this.runtimeStatus.reset();
|
|
355
379
|
this.child = null;
|
|
356
380
|
for (const pending of this.pending.values()) {
|
|
357
381
|
pending.reject(new Error(message));
|
|
@@ -369,6 +393,7 @@ export class CodexAppServerAdapter {
|
|
|
369
393
|
clientInfo: { name: 'canon-codex', version: '0.0.0' },
|
|
370
394
|
capabilities: { experimentalApi: true, supportsServerRequests: true },
|
|
371
395
|
});
|
|
396
|
+
this.write({ method: 'initialized' });
|
|
372
397
|
this.initialized = true;
|
|
373
398
|
}
|
|
374
399
|
handleLine(line) {
|
|
@@ -398,6 +423,7 @@ export class CodexAppServerAdapter {
|
|
|
398
423
|
}
|
|
399
424
|
}
|
|
400
425
|
async handleServerRequest(request) {
|
|
426
|
+
const controller = new AbortController();
|
|
401
427
|
try {
|
|
402
428
|
const params = isRecord(request.params) ? request.params : {};
|
|
403
429
|
// Detached child threads may use the ordinary coding-thread bridge, but
|
|
@@ -414,16 +440,24 @@ export class CodexAppServerAdapter {
|
|
|
414
440
|
});
|
|
415
441
|
return;
|
|
416
442
|
}
|
|
443
|
+
this.serverRequests.set(request.id, {
|
|
444
|
+
controller,
|
|
445
|
+
threadId: readString(params, 'threadId') ?? this.threadId,
|
|
446
|
+
});
|
|
417
447
|
const result = this.currentRequestHandler
|
|
418
448
|
? await this.currentRequestHandler({
|
|
419
449
|
id: request.id,
|
|
420
450
|
method: request.method,
|
|
421
451
|
params,
|
|
452
|
+
signal: controller.signal,
|
|
422
453
|
})
|
|
423
454
|
: defaultServerRequestResult(request.method);
|
|
424
|
-
|
|
455
|
+
if (!controller.signal.aborted)
|
|
456
|
+
this.write({ id: request.id, result });
|
|
425
457
|
}
|
|
426
458
|
catch (error) {
|
|
459
|
+
if (controller.signal.aborted)
|
|
460
|
+
return;
|
|
427
461
|
this.write({
|
|
428
462
|
id: request.id,
|
|
429
463
|
error: {
|
|
@@ -432,11 +466,42 @@ export class CodexAppServerAdapter {
|
|
|
432
466
|
},
|
|
433
467
|
});
|
|
434
468
|
}
|
|
469
|
+
finally {
|
|
470
|
+
if (this.serverRequests.get(request.id)?.controller === controller) {
|
|
471
|
+
this.serverRequests.delete(request.id);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
435
474
|
}
|
|
436
475
|
handleNotification(method, params) {
|
|
476
|
+
if (method === 'account/updated') {
|
|
477
|
+
this.observedSettings = {};
|
|
478
|
+
this.skillsCache = null;
|
|
479
|
+
this.onCapabilitiesChanged?.();
|
|
480
|
+
}
|
|
481
|
+
// Account events are process-wide. Tool readiness can be scoped to a thread.
|
|
482
|
+
if (this.isCurrentThreadNotification(params) && this.runtimeStatus.observe(method, params))
|
|
483
|
+
return;
|
|
484
|
+
if (method === 'serverRequest/resolved') {
|
|
485
|
+
const id = params.requestId;
|
|
486
|
+
if (typeof id !== 'number' && typeof id !== 'string')
|
|
487
|
+
return;
|
|
488
|
+
const request = this.serverRequests.get(id);
|
|
489
|
+
const threadId = readString(params, 'threadId');
|
|
490
|
+
if (request && (!threadId || threadId === request.threadId)) {
|
|
491
|
+
this.serverRequests.delete(id);
|
|
492
|
+
request.controller.abort(new Error('Codex resolved the native request'));
|
|
493
|
+
if (this.serverRequests.size === 0 && this.isRunning() && this.isCurrentThreadNotification(params)) {
|
|
494
|
+
this.currentOnEvent?.({ type: 'request.resolved' });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
437
499
|
if (method === 'skills/changed') {
|
|
438
500
|
this.skillsCache = null;
|
|
439
|
-
this.
|
|
501
|
+
if (this.onCapabilitiesChanged)
|
|
502
|
+
this.onCapabilitiesChanged();
|
|
503
|
+
else
|
|
504
|
+
this.currentOnEvent?.({ type: 'skills.changed' });
|
|
440
505
|
return;
|
|
441
506
|
}
|
|
442
507
|
if (!this.isCurrentThreadNotification(params))
|
|
@@ -596,6 +661,10 @@ export class CodexAppServerAdapter {
|
|
|
596
661
|
this.clearActiveTurn();
|
|
597
662
|
}
|
|
598
663
|
clearActiveTurn() {
|
|
664
|
+
for (const { controller } of this.serverRequests.values()) {
|
|
665
|
+
controller.abort(new Error('Codex turn ended'));
|
|
666
|
+
}
|
|
667
|
+
this.serverRequests.clear();
|
|
599
668
|
this.currentTurnId = null;
|
|
600
669
|
this.currentOnEvent = null;
|
|
601
670
|
this.currentOnLog = null;
|
|
@@ -768,9 +837,13 @@ function parseModelListResponse(result) {
|
|
|
768
837
|
})
|
|
769
838
|
: [];
|
|
770
839
|
const defaultReasoningEffort = readString(entry, 'defaultReasoningEffort');
|
|
840
|
+
const inputModalities = Array.isArray(entry.inputModalities)
|
|
841
|
+
? [...new Set(entry.inputModalities.filter((value) => typeof value === 'string' && /^[a-z][a-z_-]{0,31}$/.test(value)))]
|
|
842
|
+
: [];
|
|
771
843
|
models.push({
|
|
772
844
|
id,
|
|
773
845
|
model,
|
|
846
|
+
...(inputModalities.length ? { inputModalities } : {}),
|
|
774
847
|
displayName,
|
|
775
848
|
...(description ? { description } : {}),
|
|
776
849
|
supportedReasoningEfforts: efforts,
|
|
@@ -839,9 +912,11 @@ function renderPlan(params) {
|
|
|
839
912
|
})), { explanation });
|
|
840
913
|
}
|
|
841
914
|
function defaultServerRequestResult(method) {
|
|
915
|
+
if (method === 'item/permissions/requestApproval')
|
|
916
|
+
return { permissions: {}, scope: 'turn' };
|
|
842
917
|
if (method.includes('requestApproval'))
|
|
843
918
|
return { decision: 'decline' };
|
|
844
919
|
if (method === 'item/tool/requestUserInput')
|
|
845
920
|
return { answers: {} };
|
|
846
|
-
|
|
921
|
+
throw new Error(`Unsupported Codex server request: ${method}`);
|
|
847
922
|
}
|
package/dist/host.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type TurnArtifactRoutingDecision, type TurnArtifactRoutingMode, type RecoveryCheckpointTracker } from '@canonmsg/coding-agent-host';
|
|
3
3
|
import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimeFact, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type PreparedExecutionEnvironment, type WorkspaceOption, type CanonWorkspaceRootMetadata, type DeliveryIntent, type RuntimeStreamingPayload, type TurnLifecycleState, type TurnOutputBlock, type TurnVerbosity, type TurnVerbosityConfig } from '@canonmsg/core';
|
|
4
|
+
import type { EndpointInboundHandoff } from '@canonmsg/core';
|
|
4
5
|
import type { AgentReplyAuthorityV1 } from '@canonmsg/backend-contracts';
|
|
5
6
|
import { CodexConversationAdapter, type CodexSandboxMode } from './adapter.js';
|
|
6
7
|
import { CodexAppServerAdapter, type CodexSkillMetadata } from './app-server-adapter.js';
|
|
@@ -36,6 +37,7 @@ interface Session {
|
|
|
36
37
|
planMode?: boolean;
|
|
37
38
|
intent: DeliveryIntent;
|
|
38
39
|
sourceMessageId?: string | null;
|
|
40
|
+
inboundHandoff?: EndpointInboundHandoff;
|
|
39
41
|
markAccepted?: boolean;
|
|
40
42
|
imagePaths?: string[];
|
|
41
43
|
mediaAddDirs?: string[];
|
|
@@ -62,6 +64,7 @@ interface Session {
|
|
|
62
64
|
currentReplyAuthority: AgentReplyAuthorityV1 | null;
|
|
63
65
|
/** Cancels Canon interactions created by the currently running Codex turn. */
|
|
64
66
|
currentTurnAbortController: AbortController | null;
|
|
67
|
+
currentInboundHandoff?: EndpointInboundHandoff;
|
|
65
68
|
/**
|
|
66
69
|
* The verbosity the RUNNING turn was opened with. Promoted off the queue
|
|
67
70
|
* entry at `runNextTurn` and never re-read mid-turn, so the live writer, the
|
package/dist/host.js
CHANGED
|
@@ -84,6 +84,7 @@ export function buildCodexSessionFacts(session) {
|
|
|
84
84
|
id: 'runtime', label: 'Runtime', value: appServer ? 'Codex app-server' : 'Codex CLI', group: 'runtime',
|
|
85
85
|
}];
|
|
86
86
|
if (appServer) {
|
|
87
|
+
facts.push(...adapter.getRuntimeFacts());
|
|
87
88
|
const observed = adapter.getObservedSettings();
|
|
88
89
|
if (observed.model)
|
|
89
90
|
facts.push({ id: 'model', label: 'Model', value: observed.model, group: 'model' });
|
|
@@ -105,6 +106,9 @@ const CODEX_UNDELIVERABLE_FINAL_WORDING = {
|
|
|
105
106
|
lead: 'The Codex host completed the turn, but Canon could not deliver the reply',
|
|
106
107
|
};
|
|
107
108
|
const IDLE_CHECK_MS = 60_000;
|
|
109
|
+
/** Explicit user controls cancel work; lifecycle shutdown must preserve it. */
|
|
110
|
+
class CodexTurnCanceledError extends Error {
|
|
111
|
+
}
|
|
108
112
|
const PLAN_REVIEW_TIMEOUT_MS = 10 * 60_000;
|
|
109
113
|
const CODEX_RUNTIME_CAPABILITIES = {
|
|
110
114
|
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
@@ -1067,7 +1071,8 @@ export async function main() {
|
|
|
1067
1071
|
}
|
|
1068
1072
|
}
|
|
1069
1073
|
async function markQueuedPromptsRejected(conversationId, prompts) {
|
|
1070
|
-
await Promise.all(prompts.map((prompt) => {
|
|
1074
|
+
await Promise.all(prompts.map(async (prompt) => {
|
|
1075
|
+
await prompt.inboundHandoff?.cancel('canceled-before-execution');
|
|
1071
1076
|
if (!prompt.markAccepted || !prompt.sourceMessageId)
|
|
1072
1077
|
return Promise.resolve();
|
|
1073
1078
|
return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
|
|
@@ -1075,10 +1080,12 @@ export async function main() {
|
|
|
1075
1080
|
}
|
|
1076
1081
|
function settleRejectedPromptCheckpoints(conversationId, prompts) {
|
|
1077
1082
|
const checkpoints = recoveryCheckpointsFor(conversationId);
|
|
1078
|
-
for (const prompt of prompts)
|
|
1079
|
-
|
|
1083
|
+
for (const prompt of prompts) {
|
|
1084
|
+
if (!prompt.inboundHandoff || prompt.inboundHandoff.phase === 'settled')
|
|
1085
|
+
checkpoints.settle(prompt.sourceMessageId);
|
|
1086
|
+
}
|
|
1080
1087
|
}
|
|
1081
|
-
function removeQueuedPrompt(conversationId, sourceMessageId) {
|
|
1088
|
+
async function removeQueuedPrompt(conversationId, sourceMessageId) {
|
|
1082
1089
|
const session = sessions.get(conversationId);
|
|
1083
1090
|
if (!session || session.queue.length === 0)
|
|
1084
1091
|
return;
|
|
@@ -1086,6 +1093,7 @@ export async function main() {
|
|
|
1086
1093
|
if (removed.length === 0)
|
|
1087
1094
|
return;
|
|
1088
1095
|
session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
|
|
1096
|
+
await Promise.all(removed.map((prompt) => prompt.inboundHandoff?.cancel('message-deleted')));
|
|
1089
1097
|
settleRejectedPromptCheckpoints(conversationId, removed);
|
|
1090
1098
|
writeTurn(session);
|
|
1091
1099
|
}
|
|
@@ -1260,6 +1268,11 @@ export async function main() {
|
|
|
1260
1268
|
if (!session)
|
|
1261
1269
|
return;
|
|
1262
1270
|
session.closed = true;
|
|
1271
|
+
// Closing a native session relinquishes only unsubmitted Canon work.
|
|
1272
|
+
for (const prompt of session.queue) {
|
|
1273
|
+
void prompt.inboundHandoff?.release('session-closed').catch(console.error);
|
|
1274
|
+
}
|
|
1275
|
+
void session.currentInboundHandoff?.release('session-closed').catch(console.error);
|
|
1263
1276
|
session.currentTurnAbortController?.abort(new Error('Codex session closed'));
|
|
1264
1277
|
session.currentTurnAbortController = null;
|
|
1265
1278
|
stopVisibleWorkSignal(session);
|
|
@@ -1277,6 +1290,7 @@ export async function main() {
|
|
|
1277
1290
|
async function resetRuntimeSession(session) {
|
|
1278
1291
|
const conversationId = session.conversationId;
|
|
1279
1292
|
session.resetRequested = true;
|
|
1293
|
+
void session.currentInboundHandoff?.cancel('canceled-before-execution').catch(console.error);
|
|
1280
1294
|
const droppedPrompts = session.queue.splice(0);
|
|
1281
1295
|
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
1282
1296
|
settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
|
|
@@ -1285,7 +1299,7 @@ export async function main() {
|
|
|
1285
1299
|
session.activeSelfContextId = null;
|
|
1286
1300
|
session.state.lastError = undefined;
|
|
1287
1301
|
if (session.running) {
|
|
1288
|
-
session.currentTurnAbortController?.abort(new
|
|
1302
|
+
session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex session reset'));
|
|
1289
1303
|
await session.adapter.interrupt();
|
|
1290
1304
|
session.turnState = 'interrupted';
|
|
1291
1305
|
}
|
|
@@ -1416,6 +1430,10 @@ export async function main() {
|
|
|
1416
1430
|
fullAuto: policy.fullAuto,
|
|
1417
1431
|
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
1418
1432
|
dynamicTools: codexDynamicTools,
|
|
1433
|
+
onCapabilitiesChanged: () => {
|
|
1434
|
+
invalidateCodexCatalog();
|
|
1435
|
+
runtimeHeartbeat.refresh();
|
|
1436
|
+
},
|
|
1419
1437
|
})
|
|
1420
1438
|
: new CodexConversationAdapter({
|
|
1421
1439
|
cwd: sessionCwd,
|
|
@@ -1498,6 +1516,7 @@ export async function main() {
|
|
|
1498
1516
|
const nextPrompt = {
|
|
1499
1517
|
prompt,
|
|
1500
1518
|
skillInvocationText: turn.skillInvocationText,
|
|
1519
|
+
inboundHandoff: turn.inboundHandoff,
|
|
1501
1520
|
intent,
|
|
1502
1521
|
sourceMessageId,
|
|
1503
1522
|
markAccepted,
|
|
@@ -1571,6 +1590,12 @@ export async function main() {
|
|
|
1571
1590
|
return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
|
|
1572
1591
|
}
|
|
1573
1592
|
async function handleCodexServerRequest(session, request, requestingUserId, sourceMessageId) {
|
|
1593
|
+
const requestSignal = AbortSignal.any([
|
|
1594
|
+
...(request.signal ? [request.signal] : []),
|
|
1595
|
+
...(session.currentTurnAbortController ? [session.currentTurnAbortController.signal] : []),
|
|
1596
|
+
]);
|
|
1597
|
+
requestSignal.throwIfAborted();
|
|
1598
|
+
const requestTurnId = session.currentTurnId;
|
|
1574
1599
|
const requestId = String(request.id);
|
|
1575
1600
|
const params = request.params;
|
|
1576
1601
|
const expiresAt = Date.now() + 30 * 60_000;
|
|
@@ -1631,12 +1656,14 @@ export async function main() {
|
|
|
1631
1656
|
...(responseRouting.responseUserId
|
|
1632
1657
|
? { responseUserId: responseRouting.responseUserId }
|
|
1633
1658
|
: {}),
|
|
1634
|
-
turnId:
|
|
1659
|
+
turnId: requestTurnId ?? undefined,
|
|
1635
1660
|
}, {
|
|
1636
1661
|
requestId: inputId,
|
|
1637
1662
|
expiresAt,
|
|
1638
|
-
signal:
|
|
1663
|
+
signal: requestSignal,
|
|
1639
1664
|
onCreated: () => {
|
|
1665
|
+
if (requestSignal.aborted)
|
|
1666
|
+
return;
|
|
1640
1667
|
session.turnState = 'waiting_input';
|
|
1641
1668
|
markTurnProgress(session);
|
|
1642
1669
|
stopVisibleWorkSignal(session);
|
|
@@ -1644,6 +1671,7 @@ export async function main() {
|
|
|
1644
1671
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1645
1672
|
},
|
|
1646
1673
|
});
|
|
1674
|
+
requestSignal.throwIfAborted();
|
|
1647
1675
|
resumeTurnFromWaiting(session);
|
|
1648
1676
|
return successfulCodexAppToolResult(response);
|
|
1649
1677
|
}
|
|
@@ -1663,7 +1691,7 @@ export async function main() {
|
|
|
1663
1691
|
card: { ...card, cardId },
|
|
1664
1692
|
cardId,
|
|
1665
1693
|
expiresAt,
|
|
1666
|
-
turnId:
|
|
1694
|
+
turnId: requestTurnId ?? undefined,
|
|
1667
1695
|
});
|
|
1668
1696
|
return successfulCodexAppToolResult({
|
|
1669
1697
|
status: 'sent',
|
|
@@ -1678,15 +1706,17 @@ export async function main() {
|
|
|
1678
1706
|
const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
|
|
1679
1707
|
const response = await runtimeRequests.request('card', session.conversationId, {
|
|
1680
1708
|
card,
|
|
1681
|
-
turnId:
|
|
1709
|
+
turnId: requestTurnId ?? undefined,
|
|
1682
1710
|
...(responseRouting.responseUserId
|
|
1683
1711
|
? { responseUserId: responseRouting.responseUserId }
|
|
1684
1712
|
: {}),
|
|
1685
1713
|
}, {
|
|
1686
1714
|
requestId: cardId,
|
|
1687
1715
|
expiresAt,
|
|
1688
|
-
signal:
|
|
1716
|
+
signal: requestSignal,
|
|
1689
1717
|
onCreated: () => {
|
|
1718
|
+
if (requestSignal.aborted)
|
|
1719
|
+
return;
|
|
1690
1720
|
session.turnState = 'waiting_input';
|
|
1691
1721
|
markTurnProgress(session);
|
|
1692
1722
|
upsertTurnBlock(session, {
|
|
@@ -1701,6 +1731,7 @@ export async function main() {
|
|
|
1701
1731
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1702
1732
|
},
|
|
1703
1733
|
});
|
|
1734
|
+
requestSignal.throwIfAborted();
|
|
1704
1735
|
completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
|
|
1705
1736
|
resumeTurnFromWaiting(session);
|
|
1706
1737
|
return successfulCodexAppToolResult(response);
|
|
@@ -1748,21 +1779,23 @@ export async function main() {
|
|
|
1748
1779
|
runtime: 'codex',
|
|
1749
1780
|
method: request.method,
|
|
1750
1781
|
requestId,
|
|
1751
|
-
turnId: readString(params, 'turnId') ??
|
|
1782
|
+
turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
|
|
1752
1783
|
handles: {
|
|
1753
1784
|
itemId: readString(params, 'itemId') ?? '',
|
|
1754
1785
|
threadId: readString(params, 'threadId') ?? '',
|
|
1755
1786
|
},
|
|
1756
1787
|
},
|
|
1757
|
-
turnId:
|
|
1788
|
+
turnId: requestTurnId ?? undefined,
|
|
1758
1789
|
...(responseRouting.responseUserId
|
|
1759
1790
|
? { responseUserId: responseRouting.responseUserId }
|
|
1760
1791
|
: {}),
|
|
1761
1792
|
}, {
|
|
1762
1793
|
requestId: cardId,
|
|
1763
1794
|
expiresAt,
|
|
1764
|
-
signal:
|
|
1795
|
+
signal: requestSignal,
|
|
1765
1796
|
onCreated: () => {
|
|
1797
|
+
if (requestSignal.aborted)
|
|
1798
|
+
return;
|
|
1766
1799
|
requestCreated = true;
|
|
1767
1800
|
session.turnState = 'waiting_input';
|
|
1768
1801
|
markTurnProgress(session);
|
|
@@ -1778,6 +1811,7 @@ export async function main() {
|
|
|
1778
1811
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1779
1812
|
},
|
|
1780
1813
|
});
|
|
1814
|
+
requestSignal.throwIfAborted();
|
|
1781
1815
|
const response = cardResult.status === 'submitted'
|
|
1782
1816
|
? {
|
|
1783
1817
|
status: 'submitted',
|
|
@@ -1797,11 +1831,12 @@ export async function main() {
|
|
|
1797
1831
|
messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
|
|
1798
1832
|
metadata: {
|
|
1799
1833
|
...outcome.metadata,
|
|
1800
|
-
turnId:
|
|
1834
|
+
turnId: requestTurnId ?? undefined,
|
|
1801
1835
|
turnSemantics: 'control',
|
|
1802
1836
|
replyBehavior: 'suppress_auto_reply',
|
|
1803
1837
|
},
|
|
1804
1838
|
});
|
|
1839
|
+
requestSignal.throwIfAborted();
|
|
1805
1840
|
completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
|
|
1806
1841
|
resumeTurnFromWaiting(session);
|
|
1807
1842
|
return response;
|
|
@@ -1818,7 +1853,7 @@ export async function main() {
|
|
|
1818
1853
|
messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
|
|
1819
1854
|
metadata: {
|
|
1820
1855
|
...outcome.metadata,
|
|
1821
|
-
turnId:
|
|
1856
|
+
turnId: requestTurnId ?? undefined,
|
|
1822
1857
|
turnSemantics: 'control',
|
|
1823
1858
|
replyBehavior: 'suppress_auto_reply',
|
|
1824
1859
|
},
|
|
@@ -1853,18 +1888,19 @@ export async function main() {
|
|
|
1853
1888
|
runtime: 'codex',
|
|
1854
1889
|
method: request.method,
|
|
1855
1890
|
requestId,
|
|
1856
|
-
turnId: readString(params, 'turnId') ??
|
|
1891
|
+
turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
|
|
1857
1892
|
handles: {
|
|
1858
1893
|
itemId: readString(params, 'itemId') ?? '',
|
|
1859
1894
|
threadId: readString(params, 'threadId') ?? '',
|
|
1860
1895
|
},
|
|
1861
1896
|
},
|
|
1862
|
-
turnId:
|
|
1897
|
+
turnId: requestTurnId ?? undefined,
|
|
1863
1898
|
}, {
|
|
1864
1899
|
requestId: inputId,
|
|
1865
1900
|
expiresAt,
|
|
1866
|
-
signal:
|
|
1901
|
+
signal: requestSignal,
|
|
1867
1902
|
});
|
|
1903
|
+
requestSignal.throwIfAborted();
|
|
1868
1904
|
resumeTurnFromWaiting(session);
|
|
1869
1905
|
return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
|
|
1870
1906
|
}
|
|
@@ -1878,7 +1914,7 @@ export async function main() {
|
|
|
1878
1914
|
const response = await runtimeRequests.request('approval', session.conversationId, {
|
|
1879
1915
|
...mappedApproval,
|
|
1880
1916
|
native: { ...mappedApproval.native, requestId, method: request.method },
|
|
1881
|
-
turnId:
|
|
1917
|
+
turnId: requestTurnId ?? undefined,
|
|
1882
1918
|
...(responseRouting.responseUserId
|
|
1883
1919
|
? { responseUserId: responseRouting.responseUserId }
|
|
1884
1920
|
: {}),
|
|
@@ -1886,8 +1922,9 @@ export async function main() {
|
|
|
1886
1922
|
}, {
|
|
1887
1923
|
requestId: approvalId,
|
|
1888
1924
|
expiresAt,
|
|
1889
|
-
signal:
|
|
1925
|
+
signal: requestSignal,
|
|
1890
1926
|
});
|
|
1927
|
+
requestSignal.throwIfAborted();
|
|
1891
1928
|
resumeTurnFromWaiting(session);
|
|
1892
1929
|
if (request.method === 'item/permissions/requestApproval') {
|
|
1893
1930
|
return response.decision === 'allow'
|
|
@@ -1899,14 +1936,15 @@ export async function main() {
|
|
|
1899
1936
|
...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
|
|
1900
1937
|
});
|
|
1901
1938
|
}
|
|
1902
|
-
|
|
1939
|
+
throw new Error(`Unsupported Codex server request: ${request.method}`);
|
|
1903
1940
|
}
|
|
1904
1941
|
async function enqueueInboundMessage(input) {
|
|
1905
1942
|
knownConversationIds.add(input.conversationId);
|
|
1906
1943
|
if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
|
|
1907
1944
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
|
|
1908
|
-
|
|
1909
|
-
|
|
1945
|
+
if (await input.inboundHandoff.settle('not-dispatched')) {
|
|
1946
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1947
|
+
}
|
|
1910
1948
|
return;
|
|
1911
1949
|
}
|
|
1912
1950
|
if (input.message.metadata?.type === 'plan_approval_reply') {
|
|
@@ -1914,8 +1952,9 @@ export async function main() {
|
|
|
1914
1952
|
// A service agent never advertises or enters plan mode. Consume stale
|
|
1915
1953
|
// replies left by an older coding descriptor instead of turning them
|
|
1916
1954
|
// into hidden plan/implementation prompts after an upgrade or restart.
|
|
1917
|
-
|
|
1918
|
-
|
|
1955
|
+
if (await input.inboundHandoff.settle('not-dispatched')) {
|
|
1956
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1957
|
+
}
|
|
1919
1958
|
return;
|
|
1920
1959
|
}
|
|
1921
1960
|
const planId = readString(input.message.metadata, 'planId');
|
|
@@ -1923,8 +1962,9 @@ export async function main() {
|
|
|
1923
1962
|
senderId: input.message.senderId,
|
|
1924
1963
|
metadata: input.message.metadata,
|
|
1925
1964
|
})) {
|
|
1926
|
-
|
|
1927
|
-
|
|
1965
|
+
if (await input.inboundHandoff.settle('not-dispatched')) {
|
|
1966
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1967
|
+
}
|
|
1928
1968
|
return;
|
|
1929
1969
|
}
|
|
1930
1970
|
const session = await getOrCreateSession(input.conversationId);
|
|
@@ -1938,6 +1978,7 @@ export async function main() {
|
|
|
1938
1978
|
enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', {
|
|
1939
1979
|
canUseCodexAppTools: input.isOwner || serviceAgentMode,
|
|
1940
1980
|
requestingUserId: getCodexRequestingUserId(input.message),
|
|
1981
|
+
inboundHandoff: input.inboundHandoff,
|
|
1941
1982
|
});
|
|
1942
1983
|
return;
|
|
1943
1984
|
}
|
|
@@ -1998,8 +2039,9 @@ export async function main() {
|
|
|
1998
2039
|
: decideAutoReply(participantContext, behavior);
|
|
1999
2040
|
if (!autoReply.allow) {
|
|
2000
2041
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
2001
|
-
|
|
2002
|
-
|
|
2042
|
+
if (await input.inboundHandoff.settle('not-dispatched')) {
|
|
2043
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
2044
|
+
}
|
|
2003
2045
|
return;
|
|
2004
2046
|
}
|
|
2005
2047
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
@@ -2034,8 +2076,9 @@ export async function main() {
|
|
|
2034
2076
|
},
|
|
2035
2077
|
...(input.replyAuthority ? { replyAuthority: input.replyAuthority } : {}),
|
|
2036
2078
|
}).catch(() => { });
|
|
2037
|
-
|
|
2038
|
-
|
|
2079
|
+
if (await input.inboundHandoff.settle('not-dispatched')) {
|
|
2080
|
+
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
2081
|
+
}
|
|
2039
2082
|
return;
|
|
2040
2083
|
}
|
|
2041
2084
|
session.activeSelfContextId = activeSelfContextId;
|
|
@@ -2054,11 +2097,13 @@ export async function main() {
|
|
|
2054
2097
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
2055
2098
|
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, {
|
|
2056
2099
|
...resolveCodexTurnModes(participantContext, input.message),
|
|
2100
|
+
inboundHandoff: input.inboundHandoff,
|
|
2057
2101
|
skillInvocationText: input.message.senderType === 'human' ? input.message.text ?? undefined : undefined,
|
|
2058
2102
|
replyAuthority: input.replyAuthority ?? null,
|
|
2059
2103
|
});
|
|
2060
2104
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
2061
|
-
session.
|
|
2105
|
+
void session.currentInboundHandoff?.cancel('canceled-before-execution').catch(console.error);
|
|
2106
|
+
session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex turn interrupted by a newer message'));
|
|
2062
2107
|
await session.adapter.interrupt().catch(() => { });
|
|
2063
2108
|
clearStreaming(input.conversationId);
|
|
2064
2109
|
typingSignals.clear(input.conversationId).catch(() => { });
|
|
@@ -2066,6 +2111,7 @@ export async function main() {
|
|
|
2066
2111
|
}
|
|
2067
2112
|
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, {
|
|
2068
2113
|
...resolveCodexTurnModes(participantContext, input.message),
|
|
2114
|
+
inboundHandoff: input.inboundHandoff,
|
|
2069
2115
|
skillInvocationText: input.message.senderType === 'human' ? input.message.text ?? undefined : undefined,
|
|
2070
2116
|
replyAuthority: input.replyAuthority ?? null,
|
|
2071
2117
|
});
|
|
@@ -2115,7 +2161,9 @@ export async function main() {
|
|
|
2115
2161
|
return;
|
|
2116
2162
|
}
|
|
2117
2163
|
const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
|
|
2118
|
-
|
|
2164
|
+
session.currentInboundHandoff = nextTurn.inboundHandoff;
|
|
2165
|
+
let inputDeferred = false;
|
|
2166
|
+
let preserveUnclaimedInput = false;
|
|
2119
2167
|
let completedOutput;
|
|
2120
2168
|
let nativeCompleted = false;
|
|
2121
2169
|
session.state.lastError = undefined;
|
|
@@ -2217,6 +2265,10 @@ export async function main() {
|
|
|
2217
2265
|
void refreshCodexSkillInventory(true).then(() => runtimeHeartbeat.refresh());
|
|
2218
2266
|
return;
|
|
2219
2267
|
}
|
|
2268
|
+
if (event.type === 'request.resolved') {
|
|
2269
|
+
resumeTurnFromWaiting(session);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2220
2272
|
if (event.type === 'settings.updated') {
|
|
2221
2273
|
if (event.model)
|
|
2222
2274
|
session.state.model = event.model;
|
|
@@ -2338,10 +2390,33 @@ export async function main() {
|
|
|
2338
2390
|
// Canon-origin queues must retain a durable execution owner, including
|
|
2339
2391
|
// legacy queues restored before endpoint migration. Native continuations
|
|
2340
2392
|
// without a Canon source message remain a separate operator path.
|
|
2341
|
-
|
|
2393
|
+
const handoff = nextTurn.inboundHandoff ?? (inboundId ? await endpoint.acquireInbound(inboundId) : undefined);
|
|
2394
|
+
session.currentInboundHandoff = handoff ?? undefined;
|
|
2395
|
+
if (inboundId && !handoff) {
|
|
2396
|
+
preserveUnclaimedInput = true;
|
|
2342
2397
|
return;
|
|
2343
|
-
|
|
2344
|
-
|
|
2398
|
+
}
|
|
2399
|
+
if (handoff && (session.resetRequested || session.currentTurnAbortController?.signal.reason instanceof CodexTurnCanceledError)) {
|
|
2400
|
+
await handoff.cancel('canceled-before-execution');
|
|
2401
|
+
}
|
|
2402
|
+
else if (handoff && (session.closed || session.currentTurnAbortController?.signal.aborted)) {
|
|
2403
|
+
await handoff.release('session-closed');
|
|
2404
|
+
}
|
|
2405
|
+
const started = handoff
|
|
2406
|
+
? await endpoint.startInbound(handoff, runTurnOnce)
|
|
2407
|
+
: { status: 'submitted', value: await runTurnOnce() };
|
|
2408
|
+
if (started.status !== 'submitted') {
|
|
2409
|
+
if (started.status === 'deferred' && !session.closed && !session.resetRequested
|
|
2410
|
+
&& !session.currentTurnAbortController?.signal.aborted) {
|
|
2411
|
+
nextTurn.inboundHandoff = handoff ?? undefined;
|
|
2412
|
+
session.queue.unshift(nextTurn);
|
|
2413
|
+
inputDeferred = true;
|
|
2414
|
+
}
|
|
2415
|
+
preserveUnclaimedInput = inputDeferred || handoff?.phase === 'released';
|
|
2416
|
+
clearStreaming(session.conversationId);
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
let result = started.value;
|
|
2345
2420
|
if (!result.interrupted
|
|
2346
2421
|
&& !result.finalMessage
|
|
2347
2422
|
&& result.exitCode
|
|
@@ -2385,7 +2460,7 @@ export async function main() {
|
|
|
2385
2460
|
: { kind: 'none', reason: result.interrupted ? 'interrupted' : session.currentTurnSilenced ? 'silent' : 'empty' };
|
|
2386
2461
|
const record = {
|
|
2387
2462
|
version: 1, turnId: session.currentTurnId, conversationId: session.conversationId,
|
|
2388
|
-
sourceMessageId:
|
|
2463
|
+
sourceMessageId: inboundId ? nextTurn.sourceMessageId : null,
|
|
2389
2464
|
nativeThreadId: result.threadId, createdAt: new Date().toISOString(), output,
|
|
2390
2465
|
audience: conversationCache.has(session.conversationId) ? {
|
|
2391
2466
|
memberIds: [...conversationCache.get(session.conversationId).memberIds].sort(),
|
|
@@ -2509,6 +2584,14 @@ export async function main() {
|
|
|
2509
2584
|
await deliverCompletedOutput(completedOutput);
|
|
2510
2585
|
}
|
|
2511
2586
|
catch (error) {
|
|
2587
|
+
if (!nativeCompleted && inboundId && session.currentInboundHandoff?.phase !== 'submitted') {
|
|
2588
|
+
// A store/preflight failure proves no native callback ran. The shared
|
|
2589
|
+
// inbox can reoffer it; a generic failure reply is not completion proof.
|
|
2590
|
+
preserveUnclaimedInput = session.currentInboundHandoff?.phase !== 'settled';
|
|
2591
|
+
clearStreaming(session.conversationId);
|
|
2592
|
+
console.error('[canon-codex] Native input awaits handoff recovery:', error);
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2512
2595
|
if (nativeCompleted) {
|
|
2513
2596
|
session.state.lastError = completedOutput
|
|
2514
2597
|
? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
|
|
@@ -2557,9 +2640,28 @@ export async function main() {
|
|
|
2557
2640
|
finally {
|
|
2558
2641
|
if (completedOutput)
|
|
2559
2642
|
activeCompletedOutputs.delete(completedOutput.turnId);
|
|
2643
|
+
const handoff = session.currentInboundHandoff;
|
|
2644
|
+
if (!inputDeferred && handoff && handoff.phase !== 'submitted' && handoff.phase !== 'settled') {
|
|
2645
|
+
preserveUnclaimedInput = true;
|
|
2646
|
+
try {
|
|
2647
|
+
await handoff.release('native-preflight');
|
|
2648
|
+
}
|
|
2649
|
+
catch (error) {
|
|
2650
|
+
// Keep the existing queue/heartbeat as the local retry owner if the
|
|
2651
|
+
// durable release itself fails. Never silently discard the handle.
|
|
2652
|
+
if (!session.closed && !session.currentTurnAbortController?.signal.aborted) {
|
|
2653
|
+
nextTurn.inboundHandoff = handoff;
|
|
2654
|
+
session.queue.unshift(nextTurn);
|
|
2655
|
+
inputDeferred = true;
|
|
2656
|
+
}
|
|
2657
|
+
console.error('[canon-codex] Failed to release unsubmitted input:', error);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2560
2660
|
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2561
2661
|
session.currentTurnAbortController = null;
|
|
2562
|
-
|
|
2662
|
+
session.currentInboundHandoff = undefined;
|
|
2663
|
+
if (!preserveUnclaimedInput)
|
|
2664
|
+
recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
|
|
2563
2665
|
stopVisibleWorkSignal(session);
|
|
2564
2666
|
session.running = false;
|
|
2565
2667
|
session.state.state = 'idle';
|
|
@@ -2575,35 +2677,11 @@ export async function main() {
|
|
|
2575
2677
|
session.lastActivity = Date.now();
|
|
2576
2678
|
writeState(session);
|
|
2577
2679
|
writeTurn(session);
|
|
2578
|
-
if (session.queue.length > 0) {
|
|
2680
|
+
if (!inputDeferred && session.queue.length > 0) {
|
|
2579
2681
|
void runNextTurn(session);
|
|
2580
2682
|
}
|
|
2581
2683
|
}
|
|
2582
2684
|
}
|
|
2583
|
-
const acceptedInboundMessageIds = new Set();
|
|
2584
|
-
const inFlightInboundMessageIds = new Set();
|
|
2585
|
-
function claimInboundMessageId(messageId) {
|
|
2586
|
-
if (!messageId)
|
|
2587
|
-
return true;
|
|
2588
|
-
if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
|
|
2589
|
-
return false;
|
|
2590
|
-
inFlightInboundMessageIds.add(messageId);
|
|
2591
|
-
return true;
|
|
2592
|
-
}
|
|
2593
|
-
function settleInboundMessageId(messageId, accepted) {
|
|
2594
|
-
if (!messageId)
|
|
2595
|
-
return;
|
|
2596
|
-
inFlightInboundMessageIds.delete(messageId);
|
|
2597
|
-
if (!accepted)
|
|
2598
|
-
return;
|
|
2599
|
-
acceptedInboundMessageIds.add(messageId);
|
|
2600
|
-
while (acceptedInboundMessageIds.size > 2_048) {
|
|
2601
|
-
const oldest = acceptedInboundMessageIds.values().next().value;
|
|
2602
|
-
if (!oldest)
|
|
2603
|
-
break;
|
|
2604
|
-
acceptedInboundMessageIds.delete(oldest);
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
2685
|
const hostAvailableExecutionModes = serviceAgentMode
|
|
2608
2686
|
? ['locked']
|
|
2609
2687
|
: [...EXECUTION_ENVIRONMENT_MODES];
|
|
@@ -2658,43 +2736,63 @@ export async function main() {
|
|
|
2658
2736
|
}),
|
|
2659
2737
|
});
|
|
2660
2738
|
let runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2739
|
+
let codexCatalogRevision = 0;
|
|
2740
|
+
let refreshedCodexCatalogRevision = -1;
|
|
2741
|
+
function invalidateCodexCatalog() {
|
|
2742
|
+
codexCatalogRevision += 1;
|
|
2743
|
+
codexModels = [];
|
|
2744
|
+
codexSkills = [];
|
|
2745
|
+
codexModelOptions = buildCodexModelOptions([], args.model);
|
|
2746
|
+
codexEffortOptions = [];
|
|
2747
|
+
codexDefaultModel = resolveCodexDefaultModel([], args.model);
|
|
2748
|
+
codexDefaultEffort = null;
|
|
2749
|
+
runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2750
|
+
}
|
|
2661
2751
|
async function refreshCodexSkillInventory(forceReload = false) {
|
|
2662
2752
|
if (!useAppServer)
|
|
2663
|
-
return;
|
|
2753
|
+
return true;
|
|
2754
|
+
const revision = codexCatalogRevision;
|
|
2664
2755
|
const probe = new CodexAppServerAdapter({
|
|
2665
2756
|
cwd: workingDir,
|
|
2666
2757
|
codexBin,
|
|
2667
2758
|
model: typeof args.model === 'string' ? args.model : null,
|
|
2668
2759
|
configOverrides: args.config ?? [],
|
|
2669
2760
|
});
|
|
2761
|
+
let succeeded = true;
|
|
2762
|
+
let models = [];
|
|
2763
|
+
let skills = [];
|
|
2670
2764
|
try {
|
|
2671
|
-
|
|
2672
|
-
if (discoveredModels.length > 0) {
|
|
2673
|
-
codexModels = discoveredModels;
|
|
2674
|
-
codexModelOptions = buildCodexModelOptions(codexModels, args.model);
|
|
2675
|
-
codexEffortOptions = buildCodexEffortOptions(codexModels);
|
|
2676
|
-
codexDefaultModel = resolveCodexDefaultModel(codexModels, args.model);
|
|
2677
|
-
codexDefaultEffort = resolveCodexEffortForModel({
|
|
2678
|
-
models: codexModels,
|
|
2679
|
-
model: codexDefaultModel,
|
|
2680
|
-
requestedEffort: configuredCodexEffort,
|
|
2681
|
-
}).value;
|
|
2682
|
-
}
|
|
2765
|
+
models = await probe.listModels();
|
|
2683
2766
|
}
|
|
2684
2767
|
catch (error) {
|
|
2768
|
+
succeeded = false;
|
|
2685
2769
|
console.error('[canon-codex] Failed to load Codex models:', error instanceof Error ? error.message : error);
|
|
2686
2770
|
}
|
|
2687
2771
|
try {
|
|
2688
|
-
|
|
2772
|
+
skills = await probe.listSkills({ forceReload });
|
|
2689
2773
|
}
|
|
2690
2774
|
catch (error) {
|
|
2691
|
-
|
|
2775
|
+
succeeded = false;
|
|
2692
2776
|
console.error('[canon-codex] Failed to load Codex skills:', error instanceof Error ? error.message : error);
|
|
2693
2777
|
}
|
|
2694
2778
|
finally {
|
|
2695
|
-
runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2696
2779
|
probe.close();
|
|
2697
2780
|
}
|
|
2781
|
+
// An account change during discovery invalidates that probe's observations.
|
|
2782
|
+
if (revision !== codexCatalogRevision)
|
|
2783
|
+
return false;
|
|
2784
|
+
codexModels = models;
|
|
2785
|
+
codexSkills = skills;
|
|
2786
|
+
codexModelOptions = buildCodexModelOptions(models, args.model);
|
|
2787
|
+
codexEffortOptions = models.length ? buildCodexEffortOptions(models) : [];
|
|
2788
|
+
codexDefaultModel = resolveCodexDefaultModel(models, args.model);
|
|
2789
|
+
codexDefaultEffort = models.length ? resolveCodexEffortForModel({
|
|
2790
|
+
models, model: codexDefaultModel, requestedEffort: configuredCodexEffort,
|
|
2791
|
+
}).value : null;
|
|
2792
|
+
runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2793
|
+
if (succeeded)
|
|
2794
|
+
refreshedCodexCatalogRevision = revision;
|
|
2795
|
+
return succeeded;
|
|
2698
2796
|
}
|
|
2699
2797
|
async function handleControlSignal(event) {
|
|
2700
2798
|
const { conversationId, type } = event;
|
|
@@ -2713,13 +2811,16 @@ export async function main() {
|
|
|
2713
2811
|
return;
|
|
2714
2812
|
}
|
|
2715
2813
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
|
|
2814
|
+
if (session.running) {
|
|
2815
|
+
void session.currentInboundHandoff?.cancel('canceled-before-execution').catch(console.error);
|
|
2816
|
+
session.currentTurnAbortController?.abort(new CodexTurnCanceledError(`Codex turn interrupted by ${type}`));
|
|
2817
|
+
}
|
|
2716
2818
|
if (type === 'stop_and_drop') {
|
|
2717
2819
|
const droppedPrompts = session.queue.splice(0);
|
|
2718
2820
|
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
2719
2821
|
settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
|
|
2720
2822
|
}
|
|
2721
2823
|
if (session.running) {
|
|
2722
|
-
session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
|
|
2723
2824
|
await session.adapter.interrupt();
|
|
2724
2825
|
}
|
|
2725
2826
|
session.turnState = 'interrupted';
|
|
@@ -2780,6 +2881,15 @@ export async function main() {
|
|
|
2780
2881
|
});
|
|
2781
2882
|
};
|
|
2782
2883
|
const refreshRuntimeDetails = async (signal) => {
|
|
2884
|
+
// Reuse the heartbeat's serialized detail lane; no second polling loop.
|
|
2885
|
+
if (refreshedCodexCatalogRevision !== codexCatalogRevision) {
|
|
2886
|
+
const refreshed = await refreshCodexSkillInventory(true);
|
|
2887
|
+
if (signal.aborted)
|
|
2888
|
+
return;
|
|
2889
|
+
// Failures retry on the existing heartbeat, not an immediate refresh loop.
|
|
2890
|
+
if (refreshed)
|
|
2891
|
+
runtimeHeartbeat.refresh();
|
|
2892
|
+
}
|
|
2783
2893
|
await refreshKnownConversationIds().catch((error) => {
|
|
2784
2894
|
console.error('[canon-codex] Failed to refresh known conversations:', error);
|
|
2785
2895
|
});
|
|
@@ -2796,6 +2906,11 @@ export async function main() {
|
|
|
2796
2906
|
if (signal.aborted)
|
|
2797
2907
|
return;
|
|
2798
2908
|
const results = await Promise.allSettled(Array.from(knownConversationIds).map(async (conversationId) => {
|
|
2909
|
+
const adapter = sessions.get(conversationId)?.adapter;
|
|
2910
|
+
if (adapter instanceof CodexAppServerAdapter)
|
|
2911
|
+
await adapter.refreshRuntimeFacts();
|
|
2912
|
+
if (signal.aborted)
|
|
2913
|
+
return;
|
|
2799
2914
|
const descriptor = runtimeDescriptor.runtimeDescriptor;
|
|
2800
2915
|
if (!descriptor)
|
|
2801
2916
|
return;
|
|
@@ -2839,17 +2954,19 @@ export async function main() {
|
|
|
2839
2954
|
await endpoint.setInboundState(event.id, 'settled', 'own-message');
|
|
2840
2955
|
return;
|
|
2841
2956
|
}
|
|
2842
|
-
|
|
2957
|
+
const handoff = await endpoint.acquireInbound(event.id);
|
|
2958
|
+
if (!handoff)
|
|
2843
2959
|
return;
|
|
2844
2960
|
recoveryCheckpointsFor(payload.conversationId).track(message.id);
|
|
2845
2961
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2846
2962
|
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2963
|
+
if (await handoff.settle('observe-only')) {
|
|
2964
|
+
recoveryCheckpointsFor(payload.conversationId).settle(message.id);
|
|
2965
|
+
}
|
|
2850
2966
|
return;
|
|
2851
2967
|
}
|
|
2852
2968
|
await enqueueInboundMessage({
|
|
2969
|
+
inboundHandoff: handoff,
|
|
2853
2970
|
conversationId: payload.conversationId,
|
|
2854
2971
|
message,
|
|
2855
2972
|
senderName: message.senderName || message.senderId,
|
|
@@ -2860,8 +2977,8 @@ export async function main() {
|
|
|
2860
2977
|
provenance: payload.provenance,
|
|
2861
2978
|
turnDispatch: payload.turnDispatch,
|
|
2862
2979
|
replyAuthority: payload.replyAuthority,
|
|
2863
|
-
}).
|
|
2864
|
-
|
|
2980
|
+
}).catch(async (error) => {
|
|
2981
|
+
await handoff.release('queue-failed');
|
|
2865
2982
|
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
|
|
2866
2983
|
throw error;
|
|
2867
2984
|
});
|
|
@@ -2877,7 +2994,7 @@ export async function main() {
|
|
|
2877
2994
|
data: payload });
|
|
2878
2995
|
},
|
|
2879
2996
|
onMessageDeleted: (payload) => {
|
|
2880
|
-
removeQueuedPrompt(payload.conversationId, payload.messageId);
|
|
2997
|
+
void removeQueuedPrompt(payload.conversationId, payload.messageId).catch(console.error);
|
|
2881
2998
|
},
|
|
2882
2999
|
onConversationUpdated: (payload) => {
|
|
2883
3000
|
handleConversationUpdated(payload);
|
|
@@ -2957,6 +3074,7 @@ export async function main() {
|
|
|
2957
3074
|
clearInterval(heartbeat);
|
|
2958
3075
|
clearInterval(idleCheck);
|
|
2959
3076
|
for (const session of sessions.values()) {
|
|
3077
|
+
void session.currentInboundHandoff?.release('host-shutdown').catch(console.error);
|
|
2960
3078
|
session.currentTurnAbortController?.abort(new Error('Codex host shutting down'));
|
|
2961
3079
|
}
|
|
2962
3080
|
runtimeRequests.dispose();
|
package/dist/model-catalog.js
CHANGED
|
@@ -26,11 +26,15 @@ export function buildCodexModelOptions(models, configuredModel) {
|
|
|
26
26
|
}
|
|
27
27
|
return Number(right.isDefault) - Number(left.isDefault);
|
|
28
28
|
});
|
|
29
|
-
const options = ordered.map((model) =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
const options = ordered.map((model) => {
|
|
30
|
+
const description = [model.description, model.inputModalities?.length
|
|
31
|
+
? `Inputs: ${model.inputModalities.join(', ')}.` : undefined].filter(Boolean).join(' ');
|
|
32
|
+
return {
|
|
33
|
+
value: model.model,
|
|
34
|
+
label: model.displayName,
|
|
35
|
+
...(description ? { description } : {}),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
34
38
|
if (configured && !options.some((option) => option.value === configured)) {
|
|
35
39
|
options.push({ value: configured, label: configured });
|
|
36
40
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CanonRuntimeFact } from '@canonmsg/core';
|
|
2
|
+
type RecordValue = Record<string, unknown>;
|
|
3
|
+
/** Retain only operational availability, never account details or raw provider errors. */
|
|
4
|
+
export declare class CodexRuntimeStatus {
|
|
5
|
+
private authenticated;
|
|
6
|
+
private limits;
|
|
7
|
+
private tools;
|
|
8
|
+
revision: number;
|
|
9
|
+
hasQuota(): boolean;
|
|
10
|
+
replaceQuota(snapshot: RecordValue): void;
|
|
11
|
+
private mergeQuota;
|
|
12
|
+
reset(): void;
|
|
13
|
+
observe(method: string, params: RecordValue): boolean;
|
|
14
|
+
facts(): CanonRuntimeFact[];
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
2
|
+
/** Retain only operational availability, never account details or raw provider errors. */
|
|
3
|
+
export class CodexRuntimeStatus {
|
|
4
|
+
authenticated = null;
|
|
5
|
+
limits = new Map();
|
|
6
|
+
tools = new Map();
|
|
7
|
+
revision = 0;
|
|
8
|
+
hasQuota() { return this.limits.size > 0; }
|
|
9
|
+
replaceQuota(snapshot) {
|
|
10
|
+
this.limits.clear();
|
|
11
|
+
const buckets = record(snapshot.rateLimitsByLimitId);
|
|
12
|
+
for (const limit of buckets ? Object.values(buckets) : [snapshot.rateLimits])
|
|
13
|
+
this.mergeQuota(limit);
|
|
14
|
+
}
|
|
15
|
+
mergeQuota(value) {
|
|
16
|
+
const limit = record(value);
|
|
17
|
+
if (!limit)
|
|
18
|
+
return;
|
|
19
|
+
const id = typeof limit.limitId === 'string' ? limit.limitId : 'default';
|
|
20
|
+
const state = { ...this.limits.get(id) };
|
|
21
|
+
for (const key of ['primary', 'secondary']) {
|
|
22
|
+
const used = record(limit[key])?.usedPercent;
|
|
23
|
+
if (typeof used === 'number' && Number.isFinite(used) && used >= 0)
|
|
24
|
+
state[key] = used;
|
|
25
|
+
}
|
|
26
|
+
if (typeof limit.spendControlReached === 'boolean')
|
|
27
|
+
state.spend = limit.spendControlReached;
|
|
28
|
+
if (typeof limit.rateLimitReachedType === 'string' && limit.rateLimitReachedType)
|
|
29
|
+
state.reason = limit.rateLimitReachedType;
|
|
30
|
+
if (Object.keys(state).length)
|
|
31
|
+
this.limits.set(id, state);
|
|
32
|
+
}
|
|
33
|
+
reset() {
|
|
34
|
+
this.revision += 1;
|
|
35
|
+
this.authenticated = null;
|
|
36
|
+
this.limits.clear();
|
|
37
|
+
this.tools.clear();
|
|
38
|
+
}
|
|
39
|
+
observe(method, params) {
|
|
40
|
+
if (method === 'account/updated') {
|
|
41
|
+
this.reset();
|
|
42
|
+
if (typeof params.authMode === 'string' && params.authMode)
|
|
43
|
+
this.authenticated = true;
|
|
44
|
+
else if (params.authMode === null)
|
|
45
|
+
this.authenticated = false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (method === 'account/rateLimits/updated') {
|
|
49
|
+
this.revision += 1;
|
|
50
|
+
this.mergeQuota(params.rateLimits);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (method === 'mcpServer/startupStatus/updated') {
|
|
54
|
+
if (typeof params.name !== 'string' || !params.name)
|
|
55
|
+
return false;
|
|
56
|
+
if (params.status === 'failed') {
|
|
57
|
+
this.tools.set(params.name, params.failureReason === 'reauthenticationRequired' ? 'auth_needed' : 'failed');
|
|
58
|
+
}
|
|
59
|
+
else if (params.status === 'ready' || params.status === 'starting') {
|
|
60
|
+
this.tools.set(params.name, params.status);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
this.tools.delete(params.name);
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
facts() {
|
|
70
|
+
const facts = [];
|
|
71
|
+
if (this.authenticated !== null)
|
|
72
|
+
facts.push({
|
|
73
|
+
id: 'provider-auth', label: 'Provider authentication', group: 'account',
|
|
74
|
+
value: this.authenticated ? 'Authenticated' : 'No account reported',
|
|
75
|
+
tone: this.authenticated ? 'good' : 'neutral',
|
|
76
|
+
});
|
|
77
|
+
const limited = [...this.limits.values()].some(limit => (limit.primary ?? 0) >= 100 || (limit.secondary ?? 0) >= 100 || limit.spend || Boolean(limit.reason));
|
|
78
|
+
if (this.limits.size)
|
|
79
|
+
facts.push({
|
|
80
|
+
id: 'provider-limits', label: 'Provider limits', group: 'limits',
|
|
81
|
+
value: limited ? 'Usage limit reached' : 'Within reported usage limits',
|
|
82
|
+
tone: limited ? 'warning' : 'good',
|
|
83
|
+
});
|
|
84
|
+
const states = [...this.tools.values()];
|
|
85
|
+
if (states.includes('auth_needed') || states.includes('failed'))
|
|
86
|
+
facts.push({
|
|
87
|
+
id: 'provider-tools', label: 'Tool connections', group: 'connection',
|
|
88
|
+
value: states.includes('auth_needed') ? 'A tool connection needs sign-in' : 'A tool connection failed',
|
|
89
|
+
tone: 'warning',
|
|
90
|
+
});
|
|
91
|
+
return facts;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.5",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,13 +28,14 @@
|
|
|
28
28
|
"smoke": "node scripts/smoke-test.mjs",
|
|
29
29
|
"smoke:attach": "node scripts/smoke-attach.mjs",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
-
"prepack": "npm run build"
|
|
31
|
+
"prepack": "npm run build",
|
|
32
|
+
"smoke:protocol": "node scripts/smoke-protocol.mjs"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@canonmsg/agent-sdk": "^11.0.
|
|
35
|
+
"@canonmsg/agent-sdk": "^11.0.2",
|
|
35
36
|
"@canonmsg/agent-tools": "^0.11.0",
|
|
36
|
-
"@canonmsg/coding-agent-host": "^0.
|
|
37
|
-
"@canonmsg/core": "^13.0.
|
|
37
|
+
"@canonmsg/coding-agent-host": "^0.10.0",
|
|
38
|
+
"@canonmsg/core": "^13.0.5",
|
|
38
39
|
"@canonmsg/rich-cards": "^0.10.6",
|
|
39
40
|
"ws": "^8.21.3"
|
|
40
41
|
},
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Uses a temporary native home, no credentials, and never starts a model turn.
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { CodexAppServerAdapter } from '../dist/app-server-adapter.js';
|
|
8
|
+
import { detectCodexCliVersion } from '../dist/codex-cli-version.js';
|
|
9
|
+
|
|
10
|
+
const codexBin = process.env.CODEX_BIN || 'codex';
|
|
11
|
+
const expected = '0.155.1';
|
|
12
|
+
const version = detectCodexCliVersion(codexBin);
|
|
13
|
+
assert.equal(version.version, expected, `This compatibility smoke targets Codex ${expected}`);
|
|
14
|
+
const home = await mkdtemp(join(tmpdir(), 'canon-codex-protocol-'));
|
|
15
|
+
const originalHome = process.env.CODEX_HOME;
|
|
16
|
+
process.env.CODEX_HOME = home;
|
|
17
|
+
const adapter = new CodexAppServerAdapter({ cwd: home, codexBin });
|
|
18
|
+
const timeout = setTimeout(() => { adapter.close(); console.error('Codex protocol smoke timed out'); process.exitCode = 1; }, 20_000);
|
|
19
|
+
try {
|
|
20
|
+
const models = await adapter.listModels();
|
|
21
|
+
assert.ok(models.length > 0, 'Native model discovery returned no models');
|
|
22
|
+
assert.ok(models.every(model => model.model && Array.isArray(model.supportedReasoningEfforts)));
|
|
23
|
+
await adapter.listSkills();
|
|
24
|
+
const account = await adapter.requestAppServer('account/read', { refreshToken: false });
|
|
25
|
+
assert.equal(account.account, null, 'Smoke must use an unauthenticated temporary home');
|
|
26
|
+
console.log(JSON.stringify({ codex: version.version, protocol: 'app-server', initialization: 'passed', modelDiscovery: 'passed', skillDiscovery: 'passed', authenticated: false, modelTurnsStarted: 0 }));
|
|
27
|
+
} finally {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
adapter.close();
|
|
30
|
+
if (originalHome === undefined) delete process.env.CODEX_HOME;
|
|
31
|
+
else process.env.CODEX_HOME = originalHome;
|
|
32
|
+
await rm(home, { recursive: true, force: true });
|
|
33
|
+
}
|