@canonmsg/codex-plugin 0.32.2 → 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 +144 -38
- 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 +3 -2
- 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
|
@@ -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,10 @@ 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
|
+
if (prompt.recoveryDeferred && prompt.sourceMessageId) {
|
|
1076
|
+
await endpoint.setInboundState(`message:${conversationId}:${prompt.sourceMessageId}`, 'settled', 'canceled-before-execution');
|
|
1077
|
+
}
|
|
1071
1078
|
if (!prompt.markAccepted || !prompt.sourceMessageId)
|
|
1072
1079
|
return Promise.resolve();
|
|
1073
1080
|
return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
|
|
@@ -1260,6 +1267,13 @@ export async function main() {
|
|
|
1260
1267
|
if (!session)
|
|
1261
1268
|
return;
|
|
1262
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
|
+
}
|
|
1263
1277
|
session.currentTurnAbortController?.abort(new Error('Codex session closed'));
|
|
1264
1278
|
session.currentTurnAbortController = null;
|
|
1265
1279
|
stopVisibleWorkSignal(session);
|
|
@@ -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,
|
|
@@ -1571,6 +1589,12 @@ export async function main() {
|
|
|
1571
1589
|
return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
|
|
1572
1590
|
}
|
|
1573
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;
|
|
1574
1598
|
const requestId = String(request.id);
|
|
1575
1599
|
const params = request.params;
|
|
1576
1600
|
const expiresAt = Date.now() + 30 * 60_000;
|
|
@@ -1631,12 +1655,14 @@ export async function main() {
|
|
|
1631
1655
|
...(responseRouting.responseUserId
|
|
1632
1656
|
? { responseUserId: responseRouting.responseUserId }
|
|
1633
1657
|
: {}),
|
|
1634
|
-
turnId:
|
|
1658
|
+
turnId: requestTurnId ?? undefined,
|
|
1635
1659
|
}, {
|
|
1636
1660
|
requestId: inputId,
|
|
1637
1661
|
expiresAt,
|
|
1638
|
-
signal:
|
|
1662
|
+
signal: requestSignal,
|
|
1639
1663
|
onCreated: () => {
|
|
1664
|
+
if (requestSignal.aborted)
|
|
1665
|
+
return;
|
|
1640
1666
|
session.turnState = 'waiting_input';
|
|
1641
1667
|
markTurnProgress(session);
|
|
1642
1668
|
stopVisibleWorkSignal(session);
|
|
@@ -1644,6 +1670,7 @@ export async function main() {
|
|
|
1644
1670
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1645
1671
|
},
|
|
1646
1672
|
});
|
|
1673
|
+
requestSignal.throwIfAborted();
|
|
1647
1674
|
resumeTurnFromWaiting(session);
|
|
1648
1675
|
return successfulCodexAppToolResult(response);
|
|
1649
1676
|
}
|
|
@@ -1663,7 +1690,7 @@ export async function main() {
|
|
|
1663
1690
|
card: { ...card, cardId },
|
|
1664
1691
|
cardId,
|
|
1665
1692
|
expiresAt,
|
|
1666
|
-
turnId:
|
|
1693
|
+
turnId: requestTurnId ?? undefined,
|
|
1667
1694
|
});
|
|
1668
1695
|
return successfulCodexAppToolResult({
|
|
1669
1696
|
status: 'sent',
|
|
@@ -1678,15 +1705,17 @@ export async function main() {
|
|
|
1678
1705
|
const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
|
|
1679
1706
|
const response = await runtimeRequests.request('card', session.conversationId, {
|
|
1680
1707
|
card,
|
|
1681
|
-
turnId:
|
|
1708
|
+
turnId: requestTurnId ?? undefined,
|
|
1682
1709
|
...(responseRouting.responseUserId
|
|
1683
1710
|
? { responseUserId: responseRouting.responseUserId }
|
|
1684
1711
|
: {}),
|
|
1685
1712
|
}, {
|
|
1686
1713
|
requestId: cardId,
|
|
1687
1714
|
expiresAt,
|
|
1688
|
-
signal:
|
|
1715
|
+
signal: requestSignal,
|
|
1689
1716
|
onCreated: () => {
|
|
1717
|
+
if (requestSignal.aborted)
|
|
1718
|
+
return;
|
|
1690
1719
|
session.turnState = 'waiting_input';
|
|
1691
1720
|
markTurnProgress(session);
|
|
1692
1721
|
upsertTurnBlock(session, {
|
|
@@ -1701,6 +1730,7 @@ export async function main() {
|
|
|
1701
1730
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1702
1731
|
},
|
|
1703
1732
|
});
|
|
1733
|
+
requestSignal.throwIfAborted();
|
|
1704
1734
|
completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
|
|
1705
1735
|
resumeTurnFromWaiting(session);
|
|
1706
1736
|
return successfulCodexAppToolResult(response);
|
|
@@ -1748,21 +1778,23 @@ export async function main() {
|
|
|
1748
1778
|
runtime: 'codex',
|
|
1749
1779
|
method: request.method,
|
|
1750
1780
|
requestId,
|
|
1751
|
-
turnId: readString(params, 'turnId') ??
|
|
1781
|
+
turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
|
|
1752
1782
|
handles: {
|
|
1753
1783
|
itemId: readString(params, 'itemId') ?? '',
|
|
1754
1784
|
threadId: readString(params, 'threadId') ?? '',
|
|
1755
1785
|
},
|
|
1756
1786
|
},
|
|
1757
|
-
turnId:
|
|
1787
|
+
turnId: requestTurnId ?? undefined,
|
|
1758
1788
|
...(responseRouting.responseUserId
|
|
1759
1789
|
? { responseUserId: responseRouting.responseUserId }
|
|
1760
1790
|
: {}),
|
|
1761
1791
|
}, {
|
|
1762
1792
|
requestId: cardId,
|
|
1763
1793
|
expiresAt,
|
|
1764
|
-
signal:
|
|
1794
|
+
signal: requestSignal,
|
|
1765
1795
|
onCreated: () => {
|
|
1796
|
+
if (requestSignal.aborted)
|
|
1797
|
+
return;
|
|
1766
1798
|
requestCreated = true;
|
|
1767
1799
|
session.turnState = 'waiting_input';
|
|
1768
1800
|
markTurnProgress(session);
|
|
@@ -1778,6 +1810,7 @@ export async function main() {
|
|
|
1778
1810
|
writeCodexStreaming(session, null, 'waiting_input');
|
|
1779
1811
|
},
|
|
1780
1812
|
});
|
|
1813
|
+
requestSignal.throwIfAborted();
|
|
1781
1814
|
const response = cardResult.status === 'submitted'
|
|
1782
1815
|
? {
|
|
1783
1816
|
status: 'submitted',
|
|
@@ -1797,11 +1830,12 @@ export async function main() {
|
|
|
1797
1830
|
messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
|
|
1798
1831
|
metadata: {
|
|
1799
1832
|
...outcome.metadata,
|
|
1800
|
-
turnId:
|
|
1833
|
+
turnId: requestTurnId ?? undefined,
|
|
1801
1834
|
turnSemantics: 'control',
|
|
1802
1835
|
replyBehavior: 'suppress_auto_reply',
|
|
1803
1836
|
},
|
|
1804
1837
|
});
|
|
1838
|
+
requestSignal.throwIfAborted();
|
|
1805
1839
|
completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
|
|
1806
1840
|
resumeTurnFromWaiting(session);
|
|
1807
1841
|
return response;
|
|
@@ -1818,7 +1852,7 @@ export async function main() {
|
|
|
1818
1852
|
messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
|
|
1819
1853
|
metadata: {
|
|
1820
1854
|
...outcome.metadata,
|
|
1821
|
-
turnId:
|
|
1855
|
+
turnId: requestTurnId ?? undefined,
|
|
1822
1856
|
turnSemantics: 'control',
|
|
1823
1857
|
replyBehavior: 'suppress_auto_reply',
|
|
1824
1858
|
},
|
|
@@ -1853,18 +1887,19 @@ export async function main() {
|
|
|
1853
1887
|
runtime: 'codex',
|
|
1854
1888
|
method: request.method,
|
|
1855
1889
|
requestId,
|
|
1856
|
-
turnId: readString(params, 'turnId') ??
|
|
1890
|
+
turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
|
|
1857
1891
|
handles: {
|
|
1858
1892
|
itemId: readString(params, 'itemId') ?? '',
|
|
1859
1893
|
threadId: readString(params, 'threadId') ?? '',
|
|
1860
1894
|
},
|
|
1861
1895
|
},
|
|
1862
|
-
turnId:
|
|
1896
|
+
turnId: requestTurnId ?? undefined,
|
|
1863
1897
|
}, {
|
|
1864
1898
|
requestId: inputId,
|
|
1865
1899
|
expiresAt,
|
|
1866
|
-
signal:
|
|
1900
|
+
signal: requestSignal,
|
|
1867
1901
|
});
|
|
1902
|
+
requestSignal.throwIfAborted();
|
|
1868
1903
|
resumeTurnFromWaiting(session);
|
|
1869
1904
|
return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
|
|
1870
1905
|
}
|
|
@@ -1878,7 +1913,7 @@ export async function main() {
|
|
|
1878
1913
|
const response = await runtimeRequests.request('approval', session.conversationId, {
|
|
1879
1914
|
...mappedApproval,
|
|
1880
1915
|
native: { ...mappedApproval.native, requestId, method: request.method },
|
|
1881
|
-
turnId:
|
|
1916
|
+
turnId: requestTurnId ?? undefined,
|
|
1882
1917
|
...(responseRouting.responseUserId
|
|
1883
1918
|
? { responseUserId: responseRouting.responseUserId }
|
|
1884
1919
|
: {}),
|
|
@@ -1886,8 +1921,9 @@ export async function main() {
|
|
|
1886
1921
|
}, {
|
|
1887
1922
|
requestId: approvalId,
|
|
1888
1923
|
expiresAt,
|
|
1889
|
-
signal:
|
|
1924
|
+
signal: requestSignal,
|
|
1890
1925
|
});
|
|
1926
|
+
requestSignal.throwIfAborted();
|
|
1891
1927
|
resumeTurnFromWaiting(session);
|
|
1892
1928
|
if (request.method === 'item/permissions/requestApproval') {
|
|
1893
1929
|
return response.decision === 'allow'
|
|
@@ -1899,7 +1935,7 @@ export async function main() {
|
|
|
1899
1935
|
...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
|
|
1900
1936
|
});
|
|
1901
1937
|
}
|
|
1902
|
-
|
|
1938
|
+
throw new Error(`Unsupported Codex server request: ${request.method}`);
|
|
1903
1939
|
}
|
|
1904
1940
|
async function enqueueInboundMessage(input) {
|
|
1905
1941
|
knownConversationIds.add(input.conversationId);
|
|
@@ -2058,7 +2094,7 @@ export async function main() {
|
|
|
2058
2094
|
replyAuthority: input.replyAuthority ?? null,
|
|
2059
2095
|
});
|
|
2060
2096
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
2061
|
-
session.currentTurnAbortController?.abort(new
|
|
2097
|
+
session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex turn interrupted by a newer message'));
|
|
2062
2098
|
await session.adapter.interrupt().catch(() => { });
|
|
2063
2099
|
clearStreaming(input.conversationId);
|
|
2064
2100
|
typingSignals.clear(input.conversationId).catch(() => { });
|
|
@@ -2116,6 +2152,8 @@ export async function main() {
|
|
|
2116
2152
|
}
|
|
2117
2153
|
const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
|
|
2118
2154
|
let journaledInput = false;
|
|
2155
|
+
let inputDeferred = false;
|
|
2156
|
+
let preserveUnclaimedInput = false;
|
|
2119
2157
|
let completedOutput;
|
|
2120
2158
|
let nativeCompleted = false;
|
|
2121
2159
|
session.state.lastError = undefined;
|
|
@@ -2217,6 +2255,10 @@ export async function main() {
|
|
|
2217
2255
|
void refreshCodexSkillInventory(true).then(() => runtimeHeartbeat.refresh());
|
|
2218
2256
|
return;
|
|
2219
2257
|
}
|
|
2258
|
+
if (event.type === 'request.resolved') {
|
|
2259
|
+
resumeTurnFromWaiting(session);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2220
2262
|
if (event.type === 'settings.updated') {
|
|
2221
2263
|
if (event.model)
|
|
2222
2264
|
session.state.model = event.model;
|
|
@@ -2509,6 +2551,33 @@ export async function main() {
|
|
|
2509
2551
|
await deliverCompletedOutput(completedOutput);
|
|
2510
2552
|
}
|
|
2511
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
|
+
}
|
|
2512
2581
|
if (nativeCompleted) {
|
|
2513
2582
|
session.state.lastError = completedOutput
|
|
2514
2583
|
? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
|
|
@@ -2559,7 +2628,8 @@ export async function main() {
|
|
|
2559
2628
|
activeCompletedOutputs.delete(completedOutput.turnId);
|
|
2560
2629
|
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2561
2630
|
session.currentTurnAbortController = null;
|
|
2562
|
-
|
|
2631
|
+
if (!preserveUnclaimedInput)
|
|
2632
|
+
recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
|
|
2563
2633
|
stopVisibleWorkSignal(session);
|
|
2564
2634
|
session.running = false;
|
|
2565
2635
|
session.state.state = 'idle';
|
|
@@ -2575,7 +2645,7 @@ export async function main() {
|
|
|
2575
2645
|
session.lastActivity = Date.now();
|
|
2576
2646
|
writeState(session);
|
|
2577
2647
|
writeTurn(session);
|
|
2578
|
-
if (session.queue.length > 0) {
|
|
2648
|
+
if (!inputDeferred && session.queue.length > 0) {
|
|
2579
2649
|
void runNextTurn(session);
|
|
2580
2650
|
}
|
|
2581
2651
|
}
|
|
@@ -2658,43 +2728,63 @@ export async function main() {
|
|
|
2658
2728
|
}),
|
|
2659
2729
|
});
|
|
2660
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
|
+
}
|
|
2661
2743
|
async function refreshCodexSkillInventory(forceReload = false) {
|
|
2662
2744
|
if (!useAppServer)
|
|
2663
|
-
return;
|
|
2745
|
+
return true;
|
|
2746
|
+
const revision = codexCatalogRevision;
|
|
2664
2747
|
const probe = new CodexAppServerAdapter({
|
|
2665
2748
|
cwd: workingDir,
|
|
2666
2749
|
codexBin,
|
|
2667
2750
|
model: typeof args.model === 'string' ? args.model : null,
|
|
2668
2751
|
configOverrides: args.config ?? [],
|
|
2669
2752
|
});
|
|
2753
|
+
let succeeded = true;
|
|
2754
|
+
let models = [];
|
|
2755
|
+
let skills = [];
|
|
2670
2756
|
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
|
-
}
|
|
2757
|
+
models = await probe.listModels();
|
|
2683
2758
|
}
|
|
2684
2759
|
catch (error) {
|
|
2760
|
+
succeeded = false;
|
|
2685
2761
|
console.error('[canon-codex] Failed to load Codex models:', error instanceof Error ? error.message : error);
|
|
2686
2762
|
}
|
|
2687
2763
|
try {
|
|
2688
|
-
|
|
2764
|
+
skills = await probe.listSkills({ forceReload });
|
|
2689
2765
|
}
|
|
2690
2766
|
catch (error) {
|
|
2691
|
-
|
|
2767
|
+
succeeded = false;
|
|
2692
2768
|
console.error('[canon-codex] Failed to load Codex skills:', error instanceof Error ? error.message : error);
|
|
2693
2769
|
}
|
|
2694
2770
|
finally {
|
|
2695
|
-
runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
2696
2771
|
probe.close();
|
|
2697
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;
|
|
2698
2788
|
}
|
|
2699
2789
|
async function handleControlSignal(event) {
|
|
2700
2790
|
const { conversationId, type } = event;
|
|
@@ -2713,13 +2803,15 @@ export async function main() {
|
|
|
2713
2803
|
return;
|
|
2714
2804
|
}
|
|
2715
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
|
+
}
|
|
2716
2809
|
if (type === 'stop_and_drop') {
|
|
2717
2810
|
const droppedPrompts = session.queue.splice(0);
|
|
2718
2811
|
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
2719
2812
|
settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
|
|
2720
2813
|
}
|
|
2721
2814
|
if (session.running) {
|
|
2722
|
-
session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
|
|
2723
2815
|
await session.adapter.interrupt();
|
|
2724
2816
|
}
|
|
2725
2817
|
session.turnState = 'interrupted';
|
|
@@ -2780,6 +2872,15 @@ export async function main() {
|
|
|
2780
2872
|
});
|
|
2781
2873
|
};
|
|
2782
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
|
+
}
|
|
2783
2884
|
await refreshKnownConversationIds().catch((error) => {
|
|
2784
2885
|
console.error('[canon-codex] Failed to refresh known conversations:', error);
|
|
2785
2886
|
});
|
|
@@ -2796,6 +2897,11 @@ export async function main() {
|
|
|
2796
2897
|
if (signal.aborted)
|
|
2797
2898
|
return;
|
|
2798
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;
|
|
2799
2905
|
const descriptor = runtimeDescriptor.runtimeDescriptor;
|
|
2800
2906
|
if (!descriptor)
|
|
2801
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,7 +28,8 @@
|
|
|
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
35
|
"@canonmsg/agent-sdk": "^11.0.1",
|
|
@@ -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
|
+
}
|