@canonmsg/codex-plugin 0.32.1 → 0.32.3
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 +2 -0
- package/dist/host.js +155 -40
- 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 +5 -4
- 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
|
@@ -36,6 +36,8 @@ interface Session {
|
|
|
36
36
|
planMode?: boolean;
|
|
37
37
|
intent: DeliveryIntent;
|
|
38
38
|
sourceMessageId?: string | null;
|
|
39
|
+
/** A recovery gate rejected this input before any native execution. */
|
|
40
|
+
recoveryDeferred?: boolean;
|
|
39
41
|
markAccepted?: boolean;
|
|
40
42
|
imagePaths?: string[];
|
|
41
43
|
mediaAddDirs?: string[];
|
package/dist/host.js
CHANGED
|
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
|
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
8
8
|
import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, } from '@canonmsg/coding-agent-host';
|
|
9
|
-
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CANON_DIR, runtimePlanDescriptor, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, sendMessageWithRetry, isPendingCanonOperation, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
9
|
+
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, hydrateCanonReplyContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CANON_DIR, runtimePlanDescriptor, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, sendMessageWithRetry, isPendingCanonOperation, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, 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';
|
|
@@ -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,
|
|
@@ -987,7 +991,7 @@ export async function main() {
|
|
|
987
991
|
? Promise.resolve(input.hydratedPage)
|
|
988
992
|
: client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
989
993
|
]);
|
|
990
|
-
|
|
994
|
+
const hydrated = buildHydratedInboundContext({
|
|
991
995
|
agentId,
|
|
992
996
|
conversationId: input.conversationId,
|
|
993
997
|
conversation,
|
|
@@ -1004,6 +1008,15 @@ export async function main() {
|
|
|
1004
1008
|
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
1005
1009
|
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
1006
1010
|
});
|
|
1011
|
+
return {
|
|
1012
|
+
...hydrated,
|
|
1013
|
+
replyContext: await hydrateCanonReplyContext({
|
|
1014
|
+
client,
|
|
1015
|
+
conversationId: input.conversationId,
|
|
1016
|
+
message: input.message,
|
|
1017
|
+
messages: page?.messages,
|
|
1018
|
+
}),
|
|
1019
|
+
};
|
|
1007
1020
|
}
|
|
1008
1021
|
function writeState(session) {
|
|
1009
1022
|
runtimeState.writeSessionState(session.conversationId, {
|
|
@@ -1058,7 +1071,10 @@ export async function main() {
|
|
|
1058
1071
|
}
|
|
1059
1072
|
}
|
|
1060
1073
|
async function markQueuedPromptsRejected(conversationId, prompts) {
|
|
1061
|
-
await Promise.all(prompts.map((prompt) => {
|
|
1074
|
+
await Promise.all(prompts.map(async (prompt) => {
|
|
1075
|
+
if (prompt.recoveryDeferred && prompt.sourceMessageId) {
|
|
1076
|
+
await endpoint.setInboundState(`message:${conversationId}:${prompt.sourceMessageId}`, 'settled', 'canceled-before-execution');
|
|
1077
|
+
}
|
|
1062
1078
|
if (!prompt.markAccepted || !prompt.sourceMessageId)
|
|
1063
1079
|
return Promise.resolve();
|
|
1064
1080
|
return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
|
|
@@ -1251,6 +1267,13 @@ export async function main() {
|
|
|
1251
1267
|
if (!session)
|
|
1252
1268
|
return;
|
|
1253
1269
|
session.closed = true;
|
|
1270
|
+
// The local queue is going away, but never-started deferred input still
|
|
1271
|
+
// belongs to the durable inbox. Release only that queue's dedupe ownership
|
|
1272
|
+
// so an eligible reoffer can reconstruct it after eviction or removal.
|
|
1273
|
+
for (const prompt of session.queue) {
|
|
1274
|
+
if (prompt.recoveryDeferred && prompt.sourceMessageId)
|
|
1275
|
+
acceptedInboundMessageIds.delete(prompt.sourceMessageId);
|
|
1276
|
+
}
|
|
1254
1277
|
session.currentTurnAbortController?.abort(new Error('Codex session closed'));
|
|
1255
1278
|
session.currentTurnAbortController = null;
|
|
1256
1279
|
stopVisibleWorkSignal(session);
|
|
@@ -1276,7 +1299,7 @@ export async function main() {
|
|
|
1276
1299
|
session.activeSelfContextId = null;
|
|
1277
1300
|
session.state.lastError = undefined;
|
|
1278
1301
|
if (session.running) {
|
|
1279
|
-
session.currentTurnAbortController?.abort(new
|
|
1302
|
+
session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex session reset'));
|
|
1280
1303
|
await session.adapter.interrupt();
|
|
1281
1304
|
session.turnState = 'interrupted';
|
|
1282
1305
|
}
|
|
@@ -1407,6 +1430,10 @@ export async function main() {
|
|
|
1407
1430
|
fullAuto: policy.fullAuto,
|
|
1408
1431
|
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
1409
1432
|
dynamicTools: codexDynamicTools,
|
|
1433
|
+
onCapabilitiesChanged: () => {
|
|
1434
|
+
invalidateCodexCatalog();
|
|
1435
|
+
runtimeHeartbeat.refresh();
|
|
1436
|
+
},
|
|
1410
1437
|
})
|
|
1411
1438
|
: new CodexConversationAdapter({
|
|
1412
1439
|
cwd: sessionCwd,
|
|
@@ -1562,6 +1589,12 @@ export async function main() {
|
|
|
1562
1589
|
return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
|
|
1563
1590
|
}
|
|
1564
1591
|
async function handleCodexServerRequest(session, request, requestingUserId, sourceMessageId) {
|
|
1592
|
+
const requestSignal = AbortSignal.any([
|
|
1593
|
+
...(request.signal ? [request.signal] : []),
|
|
1594
|
+
...(session.currentTurnAbortController ? [session.currentTurnAbortController.signal] : []),
|
|
1595
|
+
]);
|
|
1596
|
+
requestSignal.throwIfAborted();
|
|
1597
|
+
const requestTurnId = session.currentTurnId;
|
|
1565
1598
|
const requestId = String(request.id);
|
|
1566
1599
|
const params = request.params;
|
|
1567
1600
|
const expiresAt = Date.now() + 30 * 60_000;
|
|
@@ -1622,12 +1655,14 @@ export async function main() {
|
|
|
1622
1655
|
...(responseRouting.responseUserId
|
|
1623
1656
|
? { responseUserId: responseRouting.responseUserId }
|
|
1624
1657
|
: {}),
|
|
1625
|
-
turnId:
|
|
1658
|
+
turnId: requestTurnId ?? undefined,
|
|
1626
1659
|
}, {
|
|
1627
1660
|
requestId: inputId,
|
|
1628
1661
|
expiresAt,
|
|
1629
|
-
signal:
|
|
1662
|
+
signal: requestSignal,
|
|
1630
1663
|
onCreated: () => {
|
|
1664
|
+
if (requestSignal.aborted)
|
|
1665
|
+
return;
|
|
1631
1666
|
session.turnState = 'waiting_input';
|
|
1632
1667
|
markTurnProgress(session);
|
|
1633
1668
|
stopVisibleWorkSignal(session);
|
|
@@ -1635,6 +1670,7 @@ export async function main() {
|
|
|
1635
1670
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1636
1671
|
},
|
|
1637
1672
|
});
|
|
1673
|
+
requestSignal.throwIfAborted();
|
|
1638
1674
|
resumeTurnFromWaiting(session);
|
|
1639
1675
|
return successfulCodexAppToolResult(response);
|
|
1640
1676
|
}
|
|
@@ -1654,7 +1690,7 @@ export async function main() {
|
|
|
1654
1690
|
card: { ...card, cardId },
|
|
1655
1691
|
cardId,
|
|
1656
1692
|
expiresAt,
|
|
1657
|
-
turnId:
|
|
1693
|
+
turnId: requestTurnId ?? undefined,
|
|
1658
1694
|
});
|
|
1659
1695
|
return successfulCodexAppToolResult({
|
|
1660
1696
|
status: 'sent',
|
|
@@ -1669,15 +1705,17 @@ export async function main() {
|
|
|
1669
1705
|
const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
|
|
1670
1706
|
const response = await runtimeRequests.request('card', session.conversationId, {
|
|
1671
1707
|
card,
|
|
1672
|
-
turnId:
|
|
1708
|
+
turnId: requestTurnId ?? undefined,
|
|
1673
1709
|
...(responseRouting.responseUserId
|
|
1674
1710
|
? { responseUserId: responseRouting.responseUserId }
|
|
1675
1711
|
: {}),
|
|
1676
1712
|
}, {
|
|
1677
1713
|
requestId: cardId,
|
|
1678
1714
|
expiresAt,
|
|
1679
|
-
signal:
|
|
1715
|
+
signal: requestSignal,
|
|
1680
1716
|
onCreated: () => {
|
|
1717
|
+
if (requestSignal.aborted)
|
|
1718
|
+
return;
|
|
1681
1719
|
session.turnState = 'waiting_input';
|
|
1682
1720
|
markTurnProgress(session);
|
|
1683
1721
|
upsertTurnBlock(session, {
|
|
@@ -1692,6 +1730,7 @@ export async function main() {
|
|
|
1692
1730
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1693
1731
|
},
|
|
1694
1732
|
});
|
|
1733
|
+
requestSignal.throwIfAborted();
|
|
1695
1734
|
completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
|
|
1696
1735
|
resumeTurnFromWaiting(session);
|
|
1697
1736
|
return successfulCodexAppToolResult(response);
|
|
@@ -1739,21 +1778,23 @@ export async function main() {
|
|
|
1739
1778
|
runtime: 'codex',
|
|
1740
1779
|
method: request.method,
|
|
1741
1780
|
requestId,
|
|
1742
|
-
turnId: readString(params, 'turnId') ??
|
|
1781
|
+
turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
|
|
1743
1782
|
handles: {
|
|
1744
1783
|
itemId: readString(params, 'itemId') ?? '',
|
|
1745
1784
|
threadId: readString(params, 'threadId') ?? '',
|
|
1746
1785
|
},
|
|
1747
1786
|
},
|
|
1748
|
-
turnId:
|
|
1787
|
+
turnId: requestTurnId ?? undefined,
|
|
1749
1788
|
...(responseRouting.responseUserId
|
|
1750
1789
|
? { responseUserId: responseRouting.responseUserId }
|
|
1751
1790
|
: {}),
|
|
1752
1791
|
}, {
|
|
1753
1792
|
requestId: cardId,
|
|
1754
1793
|
expiresAt,
|
|
1755
|
-
signal:
|
|
1794
|
+
signal: requestSignal,
|
|
1756
1795
|
onCreated: () => {
|
|
1796
|
+
if (requestSignal.aborted)
|
|
1797
|
+
return;
|
|
1757
1798
|
requestCreated = true;
|
|
1758
1799
|
session.turnState = 'waiting_input';
|
|
1759
1800
|
markTurnProgress(session);
|
|
@@ -1769,6 +1810,7 @@ export async function main() {
|
|
|
1769
1810
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1770
1811
|
},
|
|
1771
1812
|
});
|
|
1813
|
+
requestSignal.throwIfAborted();
|
|
1772
1814
|
const response = cardResult.status === 'submitted'
|
|
1773
1815
|
? {
|
|
1774
1816
|
status: 'submitted',
|
|
@@ -1788,11 +1830,12 @@ export async function main() {
|
|
|
1788
1830
|
messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
|
|
1789
1831
|
metadata: {
|
|
1790
1832
|
...outcome.metadata,
|
|
1791
|
-
turnId:
|
|
1833
|
+
turnId: requestTurnId ?? undefined,
|
|
1792
1834
|
turnSemantics: 'control',
|
|
1793
1835
|
replyBehavior: 'suppress_auto_reply',
|
|
1794
1836
|
},
|
|
1795
1837
|
});
|
|
1838
|
+
requestSignal.throwIfAborted();
|
|
1796
1839
|
completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
|
|
1797
1840
|
resumeTurnFromWaiting(session);
|
|
1798
1841
|
return response;
|
|
@@ -1809,7 +1852,7 @@ export async function main() {
|
|
|
1809
1852
|
messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
|
|
1810
1853
|
metadata: {
|
|
1811
1854
|
...outcome.metadata,
|
|
1812
|
-
turnId:
|
|
1855
|
+
turnId: requestTurnId ?? undefined,
|
|
1813
1856
|
turnSemantics: 'control',
|
|
1814
1857
|
replyBehavior: 'suppress_auto_reply',
|
|
1815
1858
|
},
|
|
@@ -1844,18 +1887,19 @@ export async function main() {
|
|
|
1844
1887
|
runtime: 'codex',
|
|
1845
1888
|
method: request.method,
|
|
1846
1889
|
requestId,
|
|
1847
|
-
turnId: readString(params, 'turnId') ??
|
|
1890
|
+
turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
|
|
1848
1891
|
handles: {
|
|
1849
1892
|
itemId: readString(params, 'itemId') ?? '',
|
|
1850
1893
|
threadId: readString(params, 'threadId') ?? '',
|
|
1851
1894
|
},
|
|
1852
1895
|
},
|
|
1853
|
-
turnId:
|
|
1896
|
+
turnId: requestTurnId ?? undefined,
|
|
1854
1897
|
}, {
|
|
1855
1898
|
requestId: inputId,
|
|
1856
1899
|
expiresAt,
|
|
1857
|
-
signal:
|
|
1900
|
+
signal: requestSignal,
|
|
1858
1901
|
});
|
|
1902
|
+
requestSignal.throwIfAborted();
|
|
1859
1903
|
resumeTurnFromWaiting(session);
|
|
1860
1904
|
return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
|
|
1861
1905
|
}
|
|
@@ -1869,7 +1913,7 @@ export async function main() {
|
|
|
1869
1913
|
const response = await runtimeRequests.request('approval', session.conversationId, {
|
|
1870
1914
|
...mappedApproval,
|
|
1871
1915
|
native: { ...mappedApproval.native, requestId, method: request.method },
|
|
1872
|
-
turnId:
|
|
1916
|
+
turnId: requestTurnId ?? undefined,
|
|
1873
1917
|
...(responseRouting.responseUserId
|
|
1874
1918
|
? { responseUserId: responseRouting.responseUserId }
|
|
1875
1919
|
: {}),
|
|
@@ -1877,8 +1921,9 @@ export async function main() {
|
|
|
1877
1921
|
}, {
|
|
1878
1922
|
requestId: approvalId,
|
|
1879
1923
|
expiresAt,
|
|
1880
|
-
signal:
|
|
1924
|
+
signal: requestSignal,
|
|
1881
1925
|
});
|
|
1926
|
+
requestSignal.throwIfAborted();
|
|
1882
1927
|
resumeTurnFromWaiting(session);
|
|
1883
1928
|
if (request.method === 'item/permissions/requestApproval') {
|
|
1884
1929
|
return response.decision === 'allow'
|
|
@@ -1890,7 +1935,7 @@ export async function main() {
|
|
|
1890
1935
|
...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
|
|
1891
1936
|
});
|
|
1892
1937
|
}
|
|
1893
|
-
|
|
1938
|
+
throw new Error(`Unsupported Codex server request: ${request.method}`);
|
|
1894
1939
|
}
|
|
1895
1940
|
async function enqueueInboundMessage(input) {
|
|
1896
1941
|
knownConversationIds.add(input.conversationId);
|
|
@@ -2049,7 +2094,7 @@ export async function main() {
|
|
|
2049
2094
|
replyAuthority: input.replyAuthority ?? null,
|
|
2050
2095
|
});
|
|
2051
2096
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
2052
|
-
session.currentTurnAbortController?.abort(new
|
|
2097
|
+
session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex turn interrupted by a newer message'));
|
|
2053
2098
|
await session.adapter.interrupt().catch(() => { });
|
|
2054
2099
|
clearStreaming(input.conversationId);
|
|
2055
2100
|
typingSignals.clear(input.conversationId).catch(() => { });
|
|
@@ -2107,6 +2152,8 @@ export async function main() {
|
|
|
2107
2152
|
}
|
|
2108
2153
|
const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
|
|
2109
2154
|
let journaledInput = false;
|
|
2155
|
+
let inputDeferred = false;
|
|
2156
|
+
let preserveUnclaimedInput = false;
|
|
2110
2157
|
let completedOutput;
|
|
2111
2158
|
let nativeCompleted = false;
|
|
2112
2159
|
session.state.lastError = undefined;
|
|
@@ -2208,6 +2255,10 @@ export async function main() {
|
|
|
2208
2255
|
void refreshCodexSkillInventory(true).then(() => runtimeHeartbeat.refresh());
|
|
2209
2256
|
return;
|
|
2210
2257
|
}
|
|
2258
|
+
if (event.type === 'request.resolved') {
|
|
2259
|
+
resumeTurnFromWaiting(session);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2211
2262
|
if (event.type === 'settings.updated') {
|
|
2212
2263
|
if (event.model)
|
|
2213
2264
|
session.state.model = event.model;
|
|
@@ -2500,6 +2551,33 @@ export async function main() {
|
|
|
2500
2551
|
await deliverCompletedOutput(completedOutput);
|
|
2501
2552
|
}
|
|
2502
2553
|
catch (error) {
|
|
2554
|
+
if (!journaledInput && inboundId && error instanceof Error
|
|
2555
|
+
&& 'code' in error && error.code === 'SYNC_NOT_READY') {
|
|
2556
|
+
// Recovery can be invalidated after the inbox offers an input. No
|
|
2557
|
+
// native work has started: keep this same queued turn for the host
|
|
2558
|
+
// heartbeat instead of publishing a failure or suppressing its retry
|
|
2559
|
+
// through the accepted-message dedupe cache.
|
|
2560
|
+
if (session.resetRequested || session.currentTurnAbortController?.signal.reason instanceof CodexTurnCanceledError) {
|
|
2561
|
+
await endpoint.setInboundState(inboundId, 'settled', 'canceled-before-execution');
|
|
2562
|
+
}
|
|
2563
|
+
else {
|
|
2564
|
+
preserveUnclaimedInput = true;
|
|
2565
|
+
if (!session.closed && !session.currentTurnAbortController?.signal.aborted) {
|
|
2566
|
+
nextTurn.recoveryDeferred = true;
|
|
2567
|
+
session.queue.unshift(nextTurn);
|
|
2568
|
+
inputDeferred = true;
|
|
2569
|
+
}
|
|
2570
|
+
else {
|
|
2571
|
+
// A closed/shutting-down session cannot retain the local queue.
|
|
2572
|
+
// Leave its durable offer available to the next live session.
|
|
2573
|
+
acceptedInboundMessageIds.delete(nextTurn.sourceMessageId);
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
// The attempt may already have published its empty thinking row.
|
|
2577
|
+
// With no native output there is nothing to hand off or salvage.
|
|
2578
|
+
clearStreaming(session.conversationId);
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2503
2581
|
if (nativeCompleted) {
|
|
2504
2582
|
session.state.lastError = completedOutput
|
|
2505
2583
|
? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
|
|
@@ -2550,7 +2628,8 @@ export async function main() {
|
|
|
2550
2628
|
activeCompletedOutputs.delete(completedOutput.turnId);
|
|
2551
2629
|
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2552
2630
|
session.currentTurnAbortController = null;
|
|
2553
|
-
|
|
2631
|
+
if (!preserveUnclaimedInput)
|
|
2632
|
+
recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
|
|
2554
2633
|
stopVisibleWorkSignal(session);
|
|
2555
2634
|
session.running = false;
|
|
2556
2635
|
session.state.state = 'idle';
|
|
@@ -2566,7 +2645,7 @@ export async function main() {
|
|
|
2566
2645
|
session.lastActivity = Date.now();
|
|
2567
2646
|
writeState(session);
|
|
2568
2647
|
writeTurn(session);
|
|
2569
|
-
if (session.queue.length > 0) {
|
|
2648
|
+
if (!inputDeferred && session.queue.length > 0) {
|
|
2570
2649
|
void runNextTurn(session);
|
|
2571
2650
|
}
|
|
2572
2651
|
}
|
|
@@ -2649,43 +2728,63 @@ export async function main() {
|
|
|
2649
2728
|
}),
|
|
2650
2729
|
});
|
|
2651
2730
|
let runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2731
|
+
let codexCatalogRevision = 0;
|
|
2732
|
+
let refreshedCodexCatalogRevision = -1;
|
|
2733
|
+
function invalidateCodexCatalog() {
|
|
2734
|
+
codexCatalogRevision += 1;
|
|
2735
|
+
codexModels = [];
|
|
2736
|
+
codexSkills = [];
|
|
2737
|
+
codexModelOptions = buildCodexModelOptions([], args.model);
|
|
2738
|
+
codexEffortOptions = [];
|
|
2739
|
+
codexDefaultModel = resolveCodexDefaultModel([], args.model);
|
|
2740
|
+
codexDefaultEffort = null;
|
|
2741
|
+
runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2742
|
+
}
|
|
2652
2743
|
async function refreshCodexSkillInventory(forceReload = false) {
|
|
2653
2744
|
if (!useAppServer)
|
|
2654
|
-
return;
|
|
2745
|
+
return true;
|
|
2746
|
+
const revision = codexCatalogRevision;
|
|
2655
2747
|
const probe = new CodexAppServerAdapter({
|
|
2656
2748
|
cwd: workingDir,
|
|
2657
2749
|
codexBin,
|
|
2658
2750
|
model: typeof args.model === 'string' ? args.model : null,
|
|
2659
2751
|
configOverrides: args.config ?? [],
|
|
2660
2752
|
});
|
|
2753
|
+
let succeeded = true;
|
|
2754
|
+
let models = [];
|
|
2755
|
+
let skills = [];
|
|
2661
2756
|
try {
|
|
2662
|
-
|
|
2663
|
-
if (discoveredModels.length > 0) {
|
|
2664
|
-
codexModels = discoveredModels;
|
|
2665
|
-
codexModelOptions = buildCodexModelOptions(codexModels, args.model);
|
|
2666
|
-
codexEffortOptions = buildCodexEffortOptions(codexModels);
|
|
2667
|
-
codexDefaultModel = resolveCodexDefaultModel(codexModels, args.model);
|
|
2668
|
-
codexDefaultEffort = resolveCodexEffortForModel({
|
|
2669
|
-
models: codexModels,
|
|
2670
|
-
model: codexDefaultModel,
|
|
2671
|
-
requestedEffort: configuredCodexEffort,
|
|
2672
|
-
}).value;
|
|
2673
|
-
}
|
|
2757
|
+
models = await probe.listModels();
|
|
2674
2758
|
}
|
|
2675
2759
|
catch (error) {
|
|
2760
|
+
succeeded = false;
|
|
2676
2761
|
console.error('[canon-codex] Failed to load Codex models:', error instanceof Error ? error.message : error);
|
|
2677
2762
|
}
|
|
2678
2763
|
try {
|
|
2679
|
-
|
|
2764
|
+
skills = await probe.listSkills({ forceReload });
|
|
2680
2765
|
}
|
|
2681
2766
|
catch (error) {
|
|
2682
|
-
|
|
2767
|
+
succeeded = false;
|
|
2683
2768
|
console.error('[canon-codex] Failed to load Codex skills:', error instanceof Error ? error.message : error);
|
|
2684
2769
|
}
|
|
2685
2770
|
finally {
|
|
2686
|
-
runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2687
2771
|
probe.close();
|
|
2688
2772
|
}
|
|
2773
|
+
// An account change during discovery invalidates that probe's observations.
|
|
2774
|
+
if (revision !== codexCatalogRevision)
|
|
2775
|
+
return false;
|
|
2776
|
+
codexModels = models;
|
|
2777
|
+
codexSkills = skills;
|
|
2778
|
+
codexModelOptions = buildCodexModelOptions(models, args.model);
|
|
2779
|
+
codexEffortOptions = models.length ? buildCodexEffortOptions(models) : [];
|
|
2780
|
+
codexDefaultModel = resolveCodexDefaultModel(models, args.model);
|
|
2781
|
+
codexDefaultEffort = models.length ? resolveCodexEffortForModel({
|
|
2782
|
+
models, model: codexDefaultModel, requestedEffort: configuredCodexEffort,
|
|
2783
|
+
}).value : null;
|
|
2784
|
+
runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2785
|
+
if (succeeded)
|
|
2786
|
+
refreshedCodexCatalogRevision = revision;
|
|
2787
|
+
return succeeded;
|
|
2689
2788
|
}
|
|
2690
2789
|
async function handleControlSignal(event) {
|
|
2691
2790
|
const { conversationId, type } = event;
|
|
@@ -2704,13 +2803,15 @@ export async function main() {
|
|
|
2704
2803
|
return;
|
|
2705
2804
|
}
|
|
2706
2805
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
|
|
2806
|
+
if (session.running) {
|
|
2807
|
+
session.currentTurnAbortController?.abort(new CodexTurnCanceledError(`Codex turn interrupted by ${type}`));
|
|
2808
|
+
}
|
|
2707
2809
|
if (type === 'stop_and_drop') {
|
|
2708
2810
|
const droppedPrompts = session.queue.splice(0);
|
|
2709
2811
|
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
2710
2812
|
settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
|
|
2711
2813
|
}
|
|
2712
2814
|
if (session.running) {
|
|
2713
|
-
session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
|
|
2714
2815
|
await session.adapter.interrupt();
|
|
2715
2816
|
}
|
|
2716
2817
|
session.turnState = 'interrupted';
|
|
@@ -2771,6 +2872,15 @@ export async function main() {
|
|
|
2771
2872
|
});
|
|
2772
2873
|
};
|
|
2773
2874
|
const refreshRuntimeDetails = async (signal) => {
|
|
2875
|
+
// Reuse the heartbeat's serialized detail lane; no second polling loop.
|
|
2876
|
+
if (refreshedCodexCatalogRevision !== codexCatalogRevision) {
|
|
2877
|
+
const refreshed = await refreshCodexSkillInventory(true);
|
|
2878
|
+
if (signal.aborted)
|
|
2879
|
+
return;
|
|
2880
|
+
// Failures retry on the existing heartbeat, not an immediate refresh loop.
|
|
2881
|
+
if (refreshed)
|
|
2882
|
+
runtimeHeartbeat.refresh();
|
|
2883
|
+
}
|
|
2774
2884
|
await refreshKnownConversationIds().catch((error) => {
|
|
2775
2885
|
console.error('[canon-codex] Failed to refresh known conversations:', error);
|
|
2776
2886
|
});
|
|
@@ -2787,6 +2897,11 @@ export async function main() {
|
|
|
2787
2897
|
if (signal.aborted)
|
|
2788
2898
|
return;
|
|
2789
2899
|
const results = await Promise.allSettled(Array.from(knownConversationIds).map(async (conversationId) => {
|
|
2900
|
+
const adapter = sessions.get(conversationId)?.adapter;
|
|
2901
|
+
if (adapter instanceof CodexAppServerAdapter)
|
|
2902
|
+
await adapter.refreshRuntimeFacts();
|
|
2903
|
+
if (signal.aborted)
|
|
2904
|
+
return;
|
|
2790
2905
|
const descriptor = runtimeDescriptor.runtimeDescriptor;
|
|
2791
2906
|
if (!descriptor)
|
|
2792
2907
|
return;
|
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.3",
|
|
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.1",
|
|
35
36
|
"@canonmsg/agent-tools": "^0.11.0",
|
|
36
37
|
"@canonmsg/coding-agent-host": "^0.9.0",
|
|
37
|
-
"@canonmsg/core": "^13.0.
|
|
38
|
+
"@canonmsg/core": "^13.0.4",
|
|
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
|
+
}
|