@canonmsg/codex-plugin 0.29.4 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,769 @@
1
+ import { createRequire } from 'node:module';
2
+ import { mapCodexAppServerApprovalRequest, mapCanonApprovalResultToCodexDecision } from './app-server-approval.js';
3
+ export class CodexAttachedSessionError extends Error {
4
+ submissionUncertain;
5
+ constructor(message, submissionUncertain = false) {
6
+ super(message);
7
+ this.submissionUncertain = submissionUncertain;
8
+ this.name = 'CodexAttachedSessionError';
9
+ }
10
+ }
11
+ export class CodexSharedRpcError extends Error {
12
+ code;
13
+ constructor(message, code) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = 'CodexAttachedSessionRpcError';
17
+ }
18
+ }
19
+ /** Explicit local endpoints only. Never discover or fall back to a private runner. */
20
+ export function validateAttachedSessionEndpoint(value) {
21
+ let url;
22
+ try {
23
+ url = new URL(value);
24
+ }
25
+ catch {
26
+ throw new Error('Specify a loopback WebSocket endpoint for the shared Codex server.');
27
+ }
28
+ if (url.protocol !== 'ws:'
29
+ || !['127.0.0.1', '[::1]'].includes(url.hostname)
30
+ || !url.port
31
+ || url.username || url.password || url.search || url.hash) {
32
+ throw new Error('The shared Codex endpoint must use ws://127.0.0.1:PORT or ws://[::1]:PORT without credentials, query, or fragment.');
33
+ }
34
+ return url.toString();
35
+ }
36
+ function createSocket(endpoint, timeoutMs) {
37
+ const WebSocket = createRequire(import.meta.url)('ws');
38
+ return new WebSocket(endpoint, {
39
+ handshakeTimeout: timeoutMs,
40
+ followRedirects: false,
41
+ perMessageDeflate: false,
42
+ maxPayload: 8 * 1024 * 1024,
43
+ });
44
+ }
45
+ function requireStableItemProtocol(result) {
46
+ const userAgent = string(object(result)?.userAgent);
47
+ // Codex uses the first client's originator as the product name, including
48
+ // our own client name on a fresh server. The following version is Codex's.
49
+ const match = userAgent?.match(/^[^\s/][^/\r\n]*\/(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?:\s|$)/);
50
+ // 0.147 rebuilds snapshot item IDs, unlike the tested 0.154 protocol. Numeric
51
+ // comparison also admits the bundled 0.154.0-alpha.6.2 build used by the smoke.
52
+ const supported = match && (Number(match[1]) > 0 || Number(match[2]) >= 154);
53
+ if (!supported) {
54
+ const detected = match ? `Detected ${match[1]}.${match[2]}.${match[3]}. ` : 'The server version could not be verified. ';
55
+ throw new CodexAttachedSessionError(`${detected}Canon attachment requires Codex 0.154.0 or newer with stable transcript item IDs. Upgrade the shared server before attaching.`);
56
+ }
57
+ }
58
+ export class CodexSharedConnection {
59
+ options;
60
+ shared;
61
+ endpoint;
62
+ timeoutMs;
63
+ pending = new Map();
64
+ socket = null;
65
+ connecting = null;
66
+ rejectOpen = null;
67
+ sequence = 0;
68
+ closed = false;
69
+ fault = null;
70
+ observers = new Set();
71
+ createdThreads = new Map();
72
+ pendingNativeRequests = new Map();
73
+ constructor(options, shared) {
74
+ this.options = options;
75
+ this.shared = shared;
76
+ this.endpoint = validateAttachedSessionEndpoint(options.endpoint);
77
+ this.timeoutMs = options.requestTimeoutMs ?? 10_000;
78
+ if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0) {
79
+ throw new Error('requestTimeoutMs must be a positive finite number.');
80
+ }
81
+ shared?.observers.add(this);
82
+ }
83
+ async connect() {
84
+ this.assertAvailable();
85
+ if (this.shared) {
86
+ await this.shared.connect();
87
+ this.assertAvailable();
88
+ return;
89
+ }
90
+ this.connecting ??= this.open();
91
+ await this.connecting;
92
+ this.assertAvailable();
93
+ }
94
+ async close() {
95
+ if (this.closed)
96
+ return;
97
+ this.closed = true;
98
+ if (this.shared) {
99
+ this.shared.observers.delete(this);
100
+ return;
101
+ }
102
+ this.rejectAll('Canon disconnected from the shared Codex server.');
103
+ this.rejectOpen?.(new CodexAttachedSessionError('Canon observer closed during connection.'));
104
+ const socket = this.socket;
105
+ this.socket = null;
106
+ if (!socket || socket.readyState === 3)
107
+ return;
108
+ await new Promise((resolve) => {
109
+ const timer = setTimeout(() => { socket.terminate(); resolve(); }, Math.min(this.timeoutMs, 1_000));
110
+ socket.on('close', () => { clearTimeout(timer); resolve(); });
111
+ // Closing this connection removes its subscriptions. No native interrupt,
112
+ // archive, process signal, or shared-daemon shutdown is sent.
113
+ if (socket.readyState === 0)
114
+ socket.terminate();
115
+ else
116
+ socket.close(1000, 'Canon observer closed');
117
+ });
118
+ }
119
+ async open() {
120
+ try {
121
+ const socket = (this.options.createSocket ?? createSocket)(this.endpoint, this.timeoutMs);
122
+ this.socket = socket;
123
+ await new Promise((resolve, reject) => {
124
+ const timer = setTimeout(() => this.fail(new Error('Shared Codex connection timed out.')), this.timeoutMs);
125
+ this.rejectOpen = (error) => { clearTimeout(timer); reject(error); };
126
+ socket.on('message', (data) => this.handleMessage(data));
127
+ socket.on('error', (error) => this.fail(error));
128
+ socket.on('close', () => this.fail(new Error('Shared Codex connection closed.')));
129
+ socket.on('open', () => { clearTimeout(timer); this.rejectOpen = null; resolve(); });
130
+ });
131
+ this.assertAvailable();
132
+ const initialization = await this.request('initialize', {
133
+ clientInfo: { name: 'canon-attached-session', version: '0.0.0' },
134
+ capabilities: { experimentalApi: true },
135
+ });
136
+ requireStableItemProtocol(initialization);
137
+ this.write({ method: 'initialized' });
138
+ }
139
+ catch (error) {
140
+ this.fail(error instanceof Error ? error : new Error(String(error)));
141
+ throw error;
142
+ }
143
+ }
144
+ assertAvailable() {
145
+ if (this.closed)
146
+ throw new CodexAttachedSessionError('The Canon observer is closed.');
147
+ if (this.fault)
148
+ throw new CodexAttachedSessionError(`Shared Codex connection is unavailable: ${this.fault.message}`);
149
+ this.shared?.assertAvailable();
150
+ }
151
+ request(method, params, submission = false) {
152
+ this.assertAvailable();
153
+ if (this.shared)
154
+ return this.shared.request(method, params, submission);
155
+ if (this.socket?.readyState !== 1)
156
+ throw new CodexAttachedSessionError('Shared Codex connection is not open.');
157
+ const id = ++this.sequence;
158
+ return new Promise((resolve, reject) => {
159
+ const timer = setTimeout(() => {
160
+ this.pending.delete(id);
161
+ reject(new CodexAttachedSessionError(`Shared Codex ${method} timed out.`, submission));
162
+ }, this.timeoutMs);
163
+ this.pending.set(id, {
164
+ method, submission,
165
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
166
+ reject: (error) => { clearTimeout(timer); reject(error); },
167
+ });
168
+ try {
169
+ this.write({ id, method, params }, (error) => {
170
+ if (!error)
171
+ return;
172
+ const pending = this.pending.get(id);
173
+ this.pending.delete(id);
174
+ pending?.reject(new CodexAttachedSessionError(`Shared Codex ${method} transport failed.`, submission));
175
+ this.fail(error);
176
+ });
177
+ }
178
+ catch (error) {
179
+ this.pending.delete(id);
180
+ clearTimeout(timer);
181
+ reject(new CodexAttachedSessionError(errorMessage(error), submission));
182
+ }
183
+ });
184
+ }
185
+ async createThread(params) {
186
+ const result = await this.request('thread/start', params, true);
187
+ const id = string(object(object(result)?.thread)?.id);
188
+ if (id)
189
+ this.createdThreads.set(id, { pristine: true });
190
+ return result;
191
+ }
192
+ hasCreatedThread(threadId) {
193
+ return this.shared ? this.shared.hasCreatedThread(threadId) : this.createdThreads.has(threadId);
194
+ }
195
+ isPristineCreatedThread(threadId) {
196
+ return this.shared ? this.shared.isPristineCreatedThread(threadId) : this.createdThreads.get(threadId)?.pristine === true;
197
+ }
198
+ respond(id, result) {
199
+ this.assertAvailable();
200
+ if (this.shared)
201
+ this.shared.respond(id, result);
202
+ else
203
+ this.write({ id, result });
204
+ }
205
+ replayPendingRequests(threadId) {
206
+ const owner = this.shared ?? this;
207
+ for (const [id, request] of owner.pendingNativeRequests) {
208
+ if (request.params.threadId === threadId)
209
+ this.onServerRequest(id, request.method, request.params);
210
+ }
211
+ }
212
+ write(message, callback) {
213
+ if (this.socket?.readyState !== 1)
214
+ throw new CodexAttachedSessionError('Shared Codex connection is not open.');
215
+ this.socket.send(JSON.stringify(message), callback);
216
+ }
217
+ handleMessage(raw) {
218
+ if (this.closed || this.fault)
219
+ return;
220
+ let message;
221
+ try {
222
+ message = object(JSON.parse(String(raw)));
223
+ }
224
+ catch {
225
+ this.fail(new Error('Shared Codex sent invalid JSON.'));
226
+ return;
227
+ }
228
+ if (!message)
229
+ return;
230
+ if ('id' in message) {
231
+ // The native UI retains ownership unless an observer explicitly supplies
232
+ // a Canon interaction bridge. The transport itself never answers requests.
233
+ if (typeof message.method === 'string') {
234
+ const params = object(message.params);
235
+ if (params && (typeof message.id === 'string' || typeof message.id === 'number')) {
236
+ this.pendingNativeRequests.set(message.id, { method: message.method, params });
237
+ this.onServerRequest(message.id, message.method, params);
238
+ for (const observer of this.observers)
239
+ observer.onServerRequest(message.id, message.method, params);
240
+ }
241
+ return;
242
+ }
243
+ if (typeof message.id !== 'number')
244
+ return;
245
+ const pending = this.pending.get(message.id);
246
+ if (!pending)
247
+ return;
248
+ this.pending.delete(message.id);
249
+ const error = object(message.error);
250
+ if (error)
251
+ pending.reject(new CodexSharedRpcError(string(error.message) ?? 'Codex rejected the request.', typeof error.code === 'number' ? error.code : null));
252
+ else
253
+ pending.resolve(message.result);
254
+ return;
255
+ }
256
+ const params = object(message.params);
257
+ if (typeof message.method === 'string' && params) {
258
+ if (message.method === 'serverRequest/resolved' && (typeof params.requestId === 'string' || typeof params.requestId === 'number')) {
259
+ this.pendingNativeRequests.delete(params.requestId);
260
+ }
261
+ const threadId = string(params.threadId) ?? string(object(params.turn)?.threadId);
262
+ if (threadId && ['turn/started', 'item/started', 'item/completed'].includes(message.method)) {
263
+ const created = this.createdThreads.get(threadId);
264
+ if (created)
265
+ created.pristine = false;
266
+ }
267
+ this.onNotification(message.method, params);
268
+ for (const observer of this.observers)
269
+ observer.onNotification(message.method, params);
270
+ }
271
+ }
272
+ onNotification(_method, _params) { }
273
+ onServerRequest(_id, _method, _params) { }
274
+ onDisconnected(_error) { }
275
+ rejectAll(reason) {
276
+ for (const request of this.pending.values()) {
277
+ request.reject(new CodexAttachedSessionError(`${reason} (${request.method})`, request.submission));
278
+ }
279
+ this.pending.clear();
280
+ }
281
+ fail(error) {
282
+ if (this.closed || this.fault)
283
+ return;
284
+ this.fault = error;
285
+ this.shared?.observers.delete(this);
286
+ this.rejectOpen?.(error);
287
+ this.rejectOpen = null;
288
+ this.rejectAll('Shared Codex connection was lost.');
289
+ this.onDisconnected(error);
290
+ for (const observer of this.observers)
291
+ observer.fail(error);
292
+ this.report(error);
293
+ this.socket?.terminate();
294
+ }
295
+ report(error) {
296
+ // Reporting callbacks cannot prevent pending requests/socket cleanup.
297
+ try {
298
+ this.options.onError?.(error);
299
+ }
300
+ catch { /* Caller owns its reporter. */ }
301
+ }
302
+ }
303
+ /** Read only live-thread metadata. No transcript, resume, or Canon credentials. */
304
+ export async function listLoadedCodexSessions(options, sharedConnection) {
305
+ const connection = sharedConnection ?? new CodexSharedConnection(options);
306
+ try {
307
+ await connection.connect();
308
+ const sessions = [];
309
+ const seen = new Set();
310
+ for await (const threadId of loadedThreadIds(connection)) {
311
+ if (seen.has(threadId))
312
+ continue;
313
+ seen.add(threadId);
314
+ const result = object(await connection.request('thread/read', { threadId, includeTurns: false }));
315
+ const thread = object(result?.thread);
316
+ if (!thread || thread.id !== threadId)
317
+ throw new Error('Codex returned a different native thread.');
318
+ if (statusType(thread.status) === 'notLoaded')
319
+ continue;
320
+ const name = string(thread.name);
321
+ const cwd = string(thread.cwd);
322
+ const model = string(thread.model);
323
+ const reasoningEffort = string(thread.reasoningEffort);
324
+ sessions.push({ threadId, ...(name ? { name } : {}), ...(cwd ? { cwd } : {}),
325
+ ...(sharedConnection ? { status: statusType(thread.status) === 'active' ? 'running' : 'idle',
326
+ ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}) } : {}),
327
+ });
328
+ }
329
+ return sessions;
330
+ }
331
+ finally {
332
+ if (!sharedConnection)
333
+ await connection.close();
334
+ }
335
+ }
336
+ /**
337
+ * Experimental second client of an explicitly shared, already-running server.
338
+ * It observes a native thread without owning its process, settings, or approvals.
339
+ */
340
+ export class CodexAttachedSessionAdapter extends CodexSharedConnection {
341
+ threadId;
342
+ listeners = new Set();
343
+ attachment = null;
344
+ submitting = false;
345
+ inspecting = null;
346
+ terminalTurns = new Map();
347
+ turnOrder = [];
348
+ historyRevision = 0;
349
+ eventRevision = 0;
350
+ interactions = new Map();
351
+ interactionBridge;
352
+ constructor(options) {
353
+ super(options, options.sharedConnection);
354
+ if (!options.threadId.trim() || options.threadId !== options.threadId.trim()) {
355
+ throw new Error('An exact native Codex thread ID is required.');
356
+ }
357
+ this.threadId = options.threadId;
358
+ this.interactionBridge = options.interactionBridge;
359
+ }
360
+ async subscribe(listener) {
361
+ this.assertAvailable();
362
+ this.listeners.add(listener);
363
+ // Install observation before resuming/subscribing, and before any snapshot.
364
+ this.attachment ??= this.attach();
365
+ try {
366
+ await this.attachment;
367
+ }
368
+ catch (error) {
369
+ this.listeners.delete(listener);
370
+ throw error;
371
+ }
372
+ }
373
+ async inspect() {
374
+ await this.ready();
375
+ // One complete read boundary lets Core buffer live events while pages load.
376
+ this.inspecting ??= this.snapshot().finally(() => { this.inspecting = null; });
377
+ return this.inspecting;
378
+ }
379
+ async submit(text) {
380
+ if (!text.trim())
381
+ return { status: 'not_submitted', reason: 'A non-empty message is required.' };
382
+ if (this.submitting)
383
+ return { status: 'not_submitted', reason: 'Another Canon input submission is pending.' };
384
+ this.submitting = true;
385
+ try {
386
+ let snapshot;
387
+ try {
388
+ snapshot = await this.inspect();
389
+ }
390
+ catch (error) {
391
+ return { status: 'not_submitted', reason: errorMessage(error) };
392
+ }
393
+ const input = [{ type: 'text', text, text_elements: [] }];
394
+ const activeTurnId = snapshot.activeTurnId;
395
+ const result = await this.request(activeTurnId ? 'turn/steer' : 'turn/start', activeTurnId
396
+ ? { threadId: this.threadId, expectedTurnId: activeTurnId, input }
397
+ : { threadId: this.threadId, input }, true);
398
+ const data = object(result);
399
+ const turnId = activeTurnId ? string(data?.turnId) : string(object(data?.turn)?.id);
400
+ if (!turnId || (activeTurnId && turnId !== activeTurnId)) {
401
+ throw new CodexAttachedSessionError('Codex accepted a request without a matching native turn ID; submission needs reconciliation.', true);
402
+ }
403
+ // turn/start has no atomic idle guard: a concurrent native input can make
404
+ // it append to that turn. Never claim that it necessarily created a turn.
405
+ return { status: 'accepted', turnId, mode: activeTurnId ? 'appended' : 'started_or_appended' };
406
+ }
407
+ catch (error) {
408
+ if ((error instanceof CodexSharedRpcError && [-32600, -32601, -32602].includes(error.code ?? 0))
409
+ || (error instanceof CodexAttachedSessionError && !error.submissionUncertain)) {
410
+ return { status: 'not_submitted', reason: error.message };
411
+ }
412
+ // A timeout/disconnect after writing input is uncertain. Never resend it.
413
+ throw error;
414
+ }
415
+ finally {
416
+ this.submitting = false;
417
+ }
418
+ }
419
+ async close() {
420
+ this.abortInteractions();
421
+ this.listeners.clear();
422
+ await super.close();
423
+ }
424
+ async attach() {
425
+ try {
426
+ await this.connect();
427
+ let loaded = false;
428
+ for await (const id of loadedThreadIds(this)) {
429
+ if (id === this.threadId) {
430
+ loaded = true;
431
+ break;
432
+ }
433
+ }
434
+ if (!loaded)
435
+ throw new Error('This exact thread is not loaded in the selected shared Codex server. Canon will not reopen its saved history in another runner.');
436
+ // A new thread is already subscribed on its creating connection but has
437
+ // no persisted rollout to resume until the first real user input.
438
+ if (this.hasCreatedThread(this.threadId)) {
439
+ this.replayPendingRequests(this.threadId);
440
+ return;
441
+ }
442
+ const result = await this.request('thread/resume', { threadId: this.threadId, excludeTurns: true });
443
+ this.readThread(result);
444
+ this.replayPendingRequests(this.threadId);
445
+ }
446
+ catch (error) {
447
+ this.fail(error instanceof Error ? error : new Error(String(error)));
448
+ throw error;
449
+ }
450
+ }
451
+ async ready() {
452
+ this.assertAvailable();
453
+ if (!this.attachment)
454
+ throw new CodexAttachedSessionError('Subscribe to the native session before reading or submitting.');
455
+ await this.attachment;
456
+ this.assertAvailable();
457
+ }
458
+ readThread(result) {
459
+ const thread = object(object(result)?.thread);
460
+ if (!thread || thread.id !== this.threadId)
461
+ throw new Error('Codex returned a different native thread.');
462
+ if (object(thread.status)?.type === 'notLoaded') {
463
+ const error = new Error('The attached native thread is no longer loaded.');
464
+ this.fail(error);
465
+ throw error;
466
+ }
467
+ return thread;
468
+ }
469
+ async snapshot() {
470
+ const revision = this.historyRevision;
471
+ const turns = await this.readTurnManifest();
472
+ const order = turns.map((turn) => turn.id);
473
+ // Revert/rollback can replace a suffix without changing this thread's ID.
474
+ if (this.turnOrder.some((id, index) => order[index] !== id))
475
+ this.terminalTurns.clear();
476
+ this.turnOrder = order;
477
+ const items = [];
478
+ const baselineItemIds = [];
479
+ let activeTurnId = null;
480
+ for (const turn of turns) {
481
+ const turnId = turn.id;
482
+ const active = statusType(turn.status) === 'inProgress';
483
+ if (active) {
484
+ activeTurnId = turnId;
485
+ }
486
+ const fingerprint = JSON.stringify([turn.status, turn.startedAt, turn.completedAt, turn.durationMs]);
487
+ const cached = this.terminalTurns.get(turnId);
488
+ let snapshot = !active && cached?.fingerprint === fingerprint ? cached.snapshot : undefined;
489
+ if (!snapshot) {
490
+ const eventRevision = this.eventRevision;
491
+ snapshot = await this.readTurnItems(turnId, active);
492
+ // An item/turn/history event during the read may make these pages stale.
493
+ if (!active && eventRevision === this.eventRevision)
494
+ this.terminalTurns.set(turnId, { fingerprint, snapshot });
495
+ }
496
+ items.push(...snapshot.items);
497
+ baselineItemIds.push(...snapshot.baselineItemIds);
498
+ }
499
+ if (revision !== this.historyRevision)
500
+ throw new Error('Native history changed during reconciliation; inspect it again.');
501
+ return { nativeSessionId: this.threadId, items, activeTurnId, baselineItemIds };
502
+ }
503
+ async readTurnManifest() {
504
+ // Metadata and pages are separate RPCs. A normal turn transition between
505
+ // them needs a fresh state check, not a false missing-active-turn failure.
506
+ for (let attempt = 0; attempt < 3; attempt += 1) {
507
+ const readMetadata = async () => this.readThread(await this.request('thread/read', { threadId: this.threadId, includeTurns: false }));
508
+ let thread = await readMetadata();
509
+ const turns = [];
510
+ const turnIds = new Set();
511
+ try {
512
+ for await (const raw of this.pages('thread/turns/list', { itemsView: 'notLoaded', limit: 100 })) {
513
+ const turn = object(raw);
514
+ const id = string(turn?.id);
515
+ const status = statusType(turn?.status);
516
+ if (!turn || !id || !['inProgress', 'completed', 'failed', 'interrupted'].includes(status ?? '') || turnIds.has(id)) {
517
+ throw new Error('Codex returned invalid or repeated native turn history.');
518
+ }
519
+ turnIds.add(id);
520
+ turns.push(turn);
521
+ }
522
+ }
523
+ catch (error) {
524
+ if (error instanceof CodexSharedRpcError && error.code === -32600
525
+ && /is not materialized yet; thread\/turns\/list is unavailable before first user message/.test(error.message)
526
+ && this.isPristineCreatedThread(this.threadId) && statusType(thread.status) === 'idle')
527
+ return [];
528
+ throw error;
529
+ }
530
+ const activeCount = turns.filter((turn) => statusType(turn.status) === 'inProgress').length;
531
+ const matches = () => activeCount === (statusType(thread.status) === 'active' ? 1 : 0);
532
+ if (matches())
533
+ return turns;
534
+ thread = await readMetadata();
535
+ if (matches())
536
+ return turns;
537
+ }
538
+ throw new Error('Codex history kept changing or reported an active thread without its active turn ID; inspect it again.');
539
+ }
540
+ async readTurnItems(turnId, active) {
541
+ const items = [];
542
+ const baselineItemIds = [];
543
+ const seen = new Set();
544
+ // A page holds one native item so aggregate history cannot exceed the
545
+ // transport cap. Individual oversized items still fail closed at 8 MiB.
546
+ // Display summaries omit intermediate assistant messages and are unsafe for
547
+ // the private-history baseline and lost-ACK correlation.
548
+ for await (const raw of this.pages('thread/items/list', { turnId, limit: 1 })) {
549
+ const entry = object(raw);
550
+ const existing = object(entry?.item);
551
+ const itemId = string(existing?.id);
552
+ if (entry?.turnId !== turnId || !existing || !itemId || seen.has(itemId)) {
553
+ throw new Error('Codex returned invalid, repeated, or mismatched native item history.');
554
+ }
555
+ seen.add(itemId);
556
+ if (existing.type === 'userMessage' && /^item-\d+$/.test(itemId)) {
557
+ // Legacy rollouts can still reconstruct IDs on a newer server.
558
+ const error = new CodexAttachedSessionError('This thread history uses reconstructed user item IDs and cannot be attached safely. Use a native thread with stable transcript item IDs on Codex 0.154.0 or newer.');
559
+ this.fail(error);
560
+ throw error;
561
+ }
562
+ if (existing.type === 'userMessage' || existing.type === 'agentMessage') {
563
+ // Even partial/empty messages existing at attachment are private.
564
+ baselineItemIds.push({ itemId, turnId });
565
+ }
566
+ const item = plainItem(existing, turnId);
567
+ if (item && (!active || item.role === 'user'))
568
+ items.push(item);
569
+ }
570
+ return { nativeSessionId: this.threadId, items, baselineItemIds, activeTurnId: active ? turnId : null };
571
+ }
572
+ async *pages(method, params) {
573
+ const seen = new Set();
574
+ let cursor;
575
+ do {
576
+ const page = object(await this.request(method, {
577
+ threadId: this.threadId, sortDirection: 'asc', ...params, ...(cursor ? { cursor } : {}),
578
+ }));
579
+ if (!page || !Array.isArray(page.data))
580
+ throw new Error(`Codex returned an invalid ${method} page.`);
581
+ for (const entry of page.data)
582
+ yield entry;
583
+ if (page.nextCursor !== undefined && page.nextCursor !== null && !string(page.nextCursor)) {
584
+ throw new Error(`Codex returned an invalid ${method} cursor.`);
585
+ }
586
+ cursor = string(page.nextCursor);
587
+ if (cursor && seen.has(cursor))
588
+ throw new Error(`Codex repeated a ${method} cursor.`);
589
+ if (cursor)
590
+ seen.add(cursor);
591
+ } while (cursor);
592
+ }
593
+ onNotification(method, params) {
594
+ const turn = object(params.turn);
595
+ const threadId = string(params.threadId) ?? string(turn?.threadId) ?? string(object(params.thread)?.id);
596
+ if (threadId !== this.threadId)
597
+ return;
598
+ if (method === 'serverRequest/resolved' && (typeof params.requestId === 'string' || typeof params.requestId === 'number')) {
599
+ this.interactions.get(params.requestId)?.abort();
600
+ this.interactions.delete(params.requestId);
601
+ return;
602
+ }
603
+ if (method === 'thread/reverted' || method === 'thread/compacted') {
604
+ this.historyRevision += 1;
605
+ this.eventRevision += 1;
606
+ this.terminalTurns.clear();
607
+ }
608
+ if (['thread/closed', 'thread/deleted', 'thread/archived'].includes(method)
609
+ || (method === 'thread/status/changed' && statusType(params.status) === 'notLoaded')) {
610
+ this.fail(new Error('The attached native thread is no longer available on the shared server.'));
611
+ return;
612
+ }
613
+ const turnId = string(params.turnId) ?? string(turn?.id);
614
+ if (!turnId)
615
+ return;
616
+ if (method.startsWith('item/') || method === 'turn/started' || method === 'turn/completed') {
617
+ this.eventRevision += 1;
618
+ this.terminalTurns.delete(turnId);
619
+ }
620
+ if (method === 'item/completed') {
621
+ const item = plainItem(params.item, turnId);
622
+ if (item)
623
+ this.emit({ type: 'item', nativeSessionId: this.threadId, item });
624
+ }
625
+ else if (method === 'turn/started') {
626
+ this.emit({ type: 'turn', nativeSessionId: this.threadId, turnId, state: 'running' });
627
+ }
628
+ else if (method === 'turn/completed') {
629
+ const status = statusType(turn?.status);
630
+ this.emit({ type: 'turn', nativeSessionId: this.threadId, turnId, state: status === 'failed' || status === 'interrupted' ? status : 'completed' });
631
+ }
632
+ }
633
+ emit(event) {
634
+ for (const listener of this.listeners) {
635
+ try {
636
+ listener(event);
637
+ }
638
+ catch (error) {
639
+ this.report(error instanceof Error ? error : new Error(String(error)));
640
+ }
641
+ }
642
+ }
643
+ onDisconnected(error) {
644
+ this.abortInteractions();
645
+ this.emit({ type: 'disconnected', nativeSessionId: this.threadId, reason: error.message });
646
+ }
647
+ abortInteractions() {
648
+ for (const controller of this.interactions.values())
649
+ controller.abort();
650
+ this.interactions.clear();
651
+ }
652
+ onServerRequest(id, method, params) {
653
+ if (!this.interactionBridge || params.threadId !== this.threadId || this.interactions.has(id))
654
+ return;
655
+ const turnId = string(params.turnId);
656
+ const identity = { nativeRequestId: JSON.stringify([turnId ?? null, id]), nativeSessionId: this.threadId, ...(turnId ? { turnId } : {}) };
657
+ const approval = mapCodexAppServerApprovalRequest({ method: method, params });
658
+ const questions = method === 'item/tool/requestUserInput' ? attachedQuestions(params.questions) : null;
659
+ let request;
660
+ if (approval)
661
+ request = { ...identity, kind: 'approval', approval };
662
+ else if (questions)
663
+ request = { ...identity, kind: 'input', input: { kind: 'clarify', title: 'Codex needs input', questions,
664
+ sensitive: questions.some((question) => question.isSecret === true),
665
+ native: { runtime: 'codex', method, requestId: String(id), ...(turnId ? { turnId } : {}) },
666
+ } };
667
+ else {
668
+ this.report(new Error(`The native session requires an unsupported interaction: ${method}. Open its native client to respond.`));
669
+ return;
670
+ }
671
+ const controller = new AbortController();
672
+ this.interactions.set(id, controller);
673
+ void Promise.resolve().then(() => controller.signal.aborted
674
+ ? { kind: 'cancelled' }
675
+ : this.interactionBridge.request(request, { signal: controller.signal })).then((result) => {
676
+ if (controller.signal.aborted || this.interactions.get(id) !== controller)
677
+ return;
678
+ if (request.kind === 'approval') {
679
+ const allow = result.kind === 'approval' && result.decision === 'allow';
680
+ this.respond(id, method === 'item/permissions/requestApproval'
681
+ ? { permissions: allow ? object(params.permissions) ?? {} : {}, scope: 'turn' }
682
+ : mapCanonApprovalResultToCodexDecision({ decision: allow ? 'allow' : 'deny' }));
683
+ }
684
+ else
685
+ this.respond(id, { answers: result.kind === 'input' ? result.answers : {} });
686
+ }).catch((error) => {
687
+ if (!controller.signal.aborted) {
688
+ this.report(error instanceof Error ? error : new Error(String(error)));
689
+ // A failed Canon interaction must not leave an unseen approval waiting
690
+ // forever. Decline/cancel safely; a native resolution aborts this path.
691
+ try {
692
+ this.respond(id, request.kind === 'input' ? { answers: {} }
693
+ : method === 'item/permissions/requestApproval' ? { permissions: {}, scope: 'turn' }
694
+ : { decision: 'decline' });
695
+ }
696
+ catch { /* A lost transport cannot carry a decision. */ }
697
+ }
698
+ }).finally(() => { if (this.interactions.get(id) === controller)
699
+ this.interactions.delete(id); });
700
+ }
701
+ }
702
+ function attachedQuestions(value) {
703
+ if (!Array.isArray(value) || !value.length || value.length > 12)
704
+ return null;
705
+ const questions = [];
706
+ for (const raw of value) {
707
+ const entry = object(raw);
708
+ const id = string(entry?.id);
709
+ const question = string(entry?.question);
710
+ if (!entry || !id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question || question.length > 1000)
711
+ return null;
712
+ const choices = Array.isArray(entry.options) ? entry.options.flatMap((raw) => {
713
+ const option = object(raw);
714
+ const label = string(option?.label);
715
+ return label ? [{ label, value: label, ...(string(option?.description) ? { description: option.description } : {}) }] : [];
716
+ }) : [];
717
+ questions.push({ id, question, ...(string(entry.header) ? { header: entry.header } : {}),
718
+ ...(choices.length ? { choices, allowOther: entry.allowOther !== false && entry.isOther !== false } : {}),
719
+ ...(entry.isSecret === true ? { isSecret: true } : {}), ...(entry.multiSelect === true ? { multiSelect: true } : {}), });
720
+ }
721
+ return questions;
722
+ }
723
+ async function* loadedThreadIds(connection) {
724
+ const seen = new Set();
725
+ let cursor;
726
+ do {
727
+ const page = object(await connection.request('thread/loaded/list', { limit: 100, ...(cursor ? { cursor } : {}) }));
728
+ if (!page || !Array.isArray(page.data))
729
+ throw new Error('Codex returned an invalid loaded-thread list.');
730
+ for (const id of page.data) {
731
+ if (typeof id !== 'string' || !id)
732
+ throw new Error('Codex returned an invalid native thread ID.');
733
+ yield id;
734
+ }
735
+ cursor = string(page.nextCursor);
736
+ if (cursor && seen.has(cursor))
737
+ throw new Error('Codex repeated a loaded-thread cursor.');
738
+ if (cursor)
739
+ seen.add(cursor);
740
+ } while (cursor);
741
+ }
742
+ function object(value) {
743
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
744
+ }
745
+ function string(value) {
746
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
747
+ }
748
+ function statusType(value) {
749
+ return string(value) ?? string(object(value)?.type);
750
+ }
751
+ function errorMessage(error) {
752
+ return error instanceof Error ? error.message : String(error);
753
+ }
754
+ function plainItem(value, turnId) {
755
+ const item = object(value);
756
+ const itemId = string(item?.id);
757
+ if (!item || !itemId)
758
+ return null;
759
+ if (item.type === 'agentMessage' && typeof item.text === 'string' && item.text.length) {
760
+ return { itemId, turnId, role: 'assistant', text: item.text };
761
+ }
762
+ if (item.type !== 'userMessage' || !Array.isArray(item.content))
763
+ return null;
764
+ const text = item.content.flatMap((raw) => {
765
+ const part = object(raw);
766
+ return part?.type === 'text' && typeof part.text === 'string' ? [part.text] : [];
767
+ }).join('\n');
768
+ return text.length ? { itemId, turnId, role: 'user', text } : null;
769
+ }