@particle-academy/prism-acp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -15
- package/dist/acp/agent.d.ts +17 -4
- package/dist/acp/agent.js +23 -9
- package/dist/acp/stdio.js +15 -5
- package/dist/claude/session-store.d.ts +15 -0
- package/dist/claude/session-store.js +47 -2
- package/dist/codex/driver.d.ts +29 -0
- package/dist/codex/driver.js +1081 -0
- package/dist/codex/rate-limit.d.ts +32 -0
- package/dist/codex/rate-limit.js +129 -0
- package/dist/codex/transport.d.ts +24 -0
- package/dist/codex/transport.js +90 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/meta.d.ts +7 -4
- package/dist/meta.js +7 -4
- package/package.json +1 -1
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
import { childEnv } from '../env.js';
|
|
2
|
+
import { JsonRpcPeer } from '../jsonrpc.js';
|
|
3
|
+
import { META_CLI_SESSION_ID, META_EXEC_POLICY_AMENDMENT, META_RATE_LIMIT, META_UNMAPPED_FRAME, withMeta, } from '../meta.js';
|
|
4
|
+
import { codexRateLimitNotice, readCodexRateLimit, } from './rate-limit.js';
|
|
5
|
+
import { StdioCodexTransport, } from './transport.js';
|
|
6
|
+
const DEFAULT_TRANSPORT_FACTORY = (options, handlers) => new StdioCodexTransport(options, handlers);
|
|
7
|
+
const APPROVAL_METHODS = new Set([
|
|
8
|
+
'item/commandExecution/requestApproval',
|
|
9
|
+
'item/fileChange/requestApproval',
|
|
10
|
+
'item/permissions/requestApproval',
|
|
11
|
+
'applyPatchApproval',
|
|
12
|
+
'execCommandApproval',
|
|
13
|
+
]);
|
|
14
|
+
const MAX_HISTORY_PAGES = 10_000;
|
|
15
|
+
const DEFAULT_TURN_INACTIVITY_TIMEOUT_MS = 600_000;
|
|
16
|
+
const KNOWN_NOTIFICATIONS = new Set([
|
|
17
|
+
'thread/started',
|
|
18
|
+
'thread/status/changed',
|
|
19
|
+
'turn/started',
|
|
20
|
+
'turn/completed',
|
|
21
|
+
'item/started',
|
|
22
|
+
'item/completed',
|
|
23
|
+
'item/agentMessage/delta',
|
|
24
|
+
'item/reasoning/summaryTextDelta',
|
|
25
|
+
'item/reasoning/textDelta',
|
|
26
|
+
'turn/plan/updated',
|
|
27
|
+
'turn/plan/delta',
|
|
28
|
+
'thread/tokenUsage/updated',
|
|
29
|
+
'account/rateLimits/updated',
|
|
30
|
+
'contextCompacted',
|
|
31
|
+
'thread/compaction/started',
|
|
32
|
+
'collabAgentSpawnBegin',
|
|
33
|
+
'collabAgentSpawnEnd',
|
|
34
|
+
'collabAgentMessage',
|
|
35
|
+
'collabAgentToolCall',
|
|
36
|
+
'collabAgentToolResult',
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* Codex App Server driver. The App Server owns the durable thread identity;
|
|
40
|
+
* this driver only captures it from thread/start or thread/resume.
|
|
41
|
+
*/
|
|
42
|
+
export class CodexDriver {
|
|
43
|
+
#options;
|
|
44
|
+
#events;
|
|
45
|
+
#transportFactory;
|
|
46
|
+
#pendingApprovals = new Map();
|
|
47
|
+
#completedTurns = new Set();
|
|
48
|
+
#items = new Map();
|
|
49
|
+
#activeItemIds = new Set();
|
|
50
|
+
#messageDeltas = new Set();
|
|
51
|
+
#reasoningDeltas = new Set();
|
|
52
|
+
#itemSequence = 0;
|
|
53
|
+
#approvalSequence = 0;
|
|
54
|
+
#transport = null;
|
|
55
|
+
#peer = null;
|
|
56
|
+
#started = false;
|
|
57
|
+
#closed = false;
|
|
58
|
+
#ready = Promise.resolve();
|
|
59
|
+
#queuedPrompt = null;
|
|
60
|
+
#activeTurn = null;
|
|
61
|
+
#turnInFlight = false;
|
|
62
|
+
#turnDeadlineTimer = null;
|
|
63
|
+
#turnDeadlineExpiresAt = 0;
|
|
64
|
+
#turnDeadlineRemainingMs = 0;
|
|
65
|
+
#turnInactivityTimeoutMs;
|
|
66
|
+
#sessionUpdateSent = false;
|
|
67
|
+
#threadId = null;
|
|
68
|
+
withheldCredentials = [];
|
|
69
|
+
cliSessionId = null;
|
|
70
|
+
constructor(options, events = {}, transportFactory = DEFAULT_TRANSPORT_FACTORY) {
|
|
71
|
+
this.#options = options;
|
|
72
|
+
this.#events = events;
|
|
73
|
+
this.#transportFactory = transportFactory;
|
|
74
|
+
this.#turnInactivityTimeoutMs = options.turnInactivityTimeoutMs ?? DEFAULT_TURN_INACTIVITY_TIMEOUT_MS;
|
|
75
|
+
if (!Number.isFinite(this.#turnInactivityTimeoutMs) || this.#turnInactivityTimeoutMs <= 0) {
|
|
76
|
+
throw new Error('turnInactivityTimeoutMs must be a positive finite number');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Resolves once initialize and thread start/resume have completed. */
|
|
80
|
+
get ready() {
|
|
81
|
+
return this.#ready;
|
|
82
|
+
}
|
|
83
|
+
start() {
|
|
84
|
+
if (this.#started)
|
|
85
|
+
throw new Error('driver already started');
|
|
86
|
+
this.#started = true;
|
|
87
|
+
if (this.#options.resumeSessionId !== undefined && isMintedAcpId(this.#options.resumeSessionId)) {
|
|
88
|
+
this.#events.onProtocolError?.(`refusing an ACP-minted session id; resume with the Codex id carried as ${META_CLI_SESSION_ID}`);
|
|
89
|
+
this.#events.onExit?.(1, null);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.#ready = this.#connect().catch((cause) => {
|
|
93
|
+
this.#events.onProtocolError?.(safeError('Codex app-server startup failed', cause));
|
|
94
|
+
this.#finishTurn({ stopReason: null, isError: true, raw: 'app-server startup failed' });
|
|
95
|
+
this.#shutdown('SIGTERM');
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
prompt(text) {
|
|
99
|
+
if (!this.#started || this.#closed)
|
|
100
|
+
throw new Error('driver is not started, or has exited');
|
|
101
|
+
if (this.#turnInFlight || this.#queuedPrompt !== null)
|
|
102
|
+
throw new Error('Codex turn already in flight');
|
|
103
|
+
this.#turnInFlight = true;
|
|
104
|
+
if (this.#peer === null || this.#threadId === null) {
|
|
105
|
+
this.#queuedPrompt = text;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.#startTurn(text);
|
|
109
|
+
}
|
|
110
|
+
/** App Server has no stdin close operation; the thread remains promptable. */
|
|
111
|
+
endInput() { }
|
|
112
|
+
kill(signal = 'SIGTERM') {
|
|
113
|
+
this.#shutdown(signal);
|
|
114
|
+
}
|
|
115
|
+
async #connect() {
|
|
116
|
+
const { env, withheld } = childEnv(this.#options.parentEnv ?? process.env, {
|
|
117
|
+
allow: ['CODEX_HOME', 'CODEX_CONFIG_DIR', 'XDG_CONFIG_HOME', ...(this.#options.allowEnv ?? [])],
|
|
118
|
+
});
|
|
119
|
+
this.withheldCredentials = withheld;
|
|
120
|
+
const handlers = {
|
|
121
|
+
onFrame: (frame) => this.#receive(frame),
|
|
122
|
+
onStderr: (line) => this.#events.onStderr?.(line),
|
|
123
|
+
onProblem: (problem) => this.#events.onProtocolError?.(problem),
|
|
124
|
+
onExit: (code, signal) => this.#onExit(code, signal),
|
|
125
|
+
};
|
|
126
|
+
const transport = this.#transportFactory({ cwd: this.#options.cwd, env, ...(this.#options.binary === undefined ? {} : { binary: this.#options.binary }) }, handlers);
|
|
127
|
+
this.#transport = transport;
|
|
128
|
+
this.#peer = new JsonRpcPeer({
|
|
129
|
+
send: (message) => transport.send(message),
|
|
130
|
+
onProtocolError: (problem, frame) => this.#recordUnmapped(problem, frame),
|
|
131
|
+
});
|
|
132
|
+
await transport.start();
|
|
133
|
+
const peer = this.#requirePeer();
|
|
134
|
+
await peer.request('initialize', {
|
|
135
|
+
clientInfo: { name: '@particle-academy/prism-acp', version: '0.4.1' },
|
|
136
|
+
capabilities: {},
|
|
137
|
+
});
|
|
138
|
+
peer.notify('initialized');
|
|
139
|
+
if (this.#options.resumeSessionId === undefined) {
|
|
140
|
+
const result = await peer.request('thread/start', {
|
|
141
|
+
cwd: this.#options.cwd,
|
|
142
|
+
approvalPolicy: 'on-request',
|
|
143
|
+
});
|
|
144
|
+
const thread = asObject(asObject(result)?.thread);
|
|
145
|
+
const id = asString(thread?.id);
|
|
146
|
+
if (id === undefined || id.length === 0)
|
|
147
|
+
throw new Error('thread/start response did not contain thread.id');
|
|
148
|
+
this.#setThreadId(id);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const result = await peer.request('thread/resume', {
|
|
152
|
+
threadId: this.#options.resumeSessionId,
|
|
153
|
+
cwd: this.#options.cwd,
|
|
154
|
+
excludeTurns: true,
|
|
155
|
+
});
|
|
156
|
+
const thread = asObject(asObject(result)?.thread);
|
|
157
|
+
const id = asString(thread?.id);
|
|
158
|
+
if (id === undefined || id.length === 0 || id !== this.#options.resumeSessionId) {
|
|
159
|
+
throw new Error('thread/resume did not return the requested Codex thread id');
|
|
160
|
+
}
|
|
161
|
+
this.#setThreadId(id);
|
|
162
|
+
await this.#replayHistory();
|
|
163
|
+
}
|
|
164
|
+
await this.#readInitialRateLimit();
|
|
165
|
+
const queued = this.#queuedPrompt;
|
|
166
|
+
this.#queuedPrompt = null;
|
|
167
|
+
if (queued !== null)
|
|
168
|
+
this.#startTurn(queued);
|
|
169
|
+
}
|
|
170
|
+
async #readInitialRateLimit() {
|
|
171
|
+
try {
|
|
172
|
+
const result = await this.#requirePeer().request('account/rateLimits/read', {});
|
|
173
|
+
this.#emitRateLimit(result);
|
|
174
|
+
}
|
|
175
|
+
catch (cause) {
|
|
176
|
+
// Account telemetry is optional; an unavailable read is not a turn error.
|
|
177
|
+
this.#recordUnmapped(safeError('account/rateLimits/read failed', cause), {
|
|
178
|
+
method: 'account/rateLimits/read',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async #replayHistory() {
|
|
183
|
+
const threadId = this.#threadId;
|
|
184
|
+
if (threadId === null)
|
|
185
|
+
return;
|
|
186
|
+
let cursor;
|
|
187
|
+
const seenCursors = new Set();
|
|
188
|
+
do {
|
|
189
|
+
if (seenCursors.size >= MAX_HISTORY_PAGES) {
|
|
190
|
+
this.#recordUnmapped('thread/turns/list exceeded the 10000-page safety limit', { method: 'thread/turns/list' });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const response = asObject(await this.#requirePeer().request('thread/turns/list', {
|
|
194
|
+
threadId,
|
|
195
|
+
sortDirection: 'asc',
|
|
196
|
+
itemsView: 'none',
|
|
197
|
+
limit: 100,
|
|
198
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
199
|
+
}));
|
|
200
|
+
if (!Array.isArray(response?.data)) {
|
|
201
|
+
this.#recordUnmapped('thread/turns/list response omitted data array', response);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const turns = response.data;
|
|
205
|
+
for (const entry of turns) {
|
|
206
|
+
const turn = asObject(entry);
|
|
207
|
+
const turnId = asString(turn?.id);
|
|
208
|
+
if (turnId === undefined) {
|
|
209
|
+
this.#recordUnmapped('thread/turns/list entry omitted id', entry);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
await this.#replayTurnItems(turnId);
|
|
213
|
+
}
|
|
214
|
+
cursor = this.#nextCursor(response, 'thread/turns/list', seenCursors);
|
|
215
|
+
} while (cursor !== undefined);
|
|
216
|
+
}
|
|
217
|
+
async #replayTurnItems(turnId) {
|
|
218
|
+
const threadId = this.#threadId;
|
|
219
|
+
if (threadId === null)
|
|
220
|
+
return;
|
|
221
|
+
let cursor;
|
|
222
|
+
const seenCursors = new Set();
|
|
223
|
+
do {
|
|
224
|
+
if (seenCursors.size >= MAX_HISTORY_PAGES) {
|
|
225
|
+
this.#recordUnmapped('thread/items/list exceeded the 10000-page safety limit', {
|
|
226
|
+
method: 'thread/items/list',
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const response = asObject(await this.#requirePeer().request('thread/items/list', {
|
|
231
|
+
threadId,
|
|
232
|
+
turnId,
|
|
233
|
+
sortDirection: 'asc',
|
|
234
|
+
limit: 100,
|
|
235
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
236
|
+
}));
|
|
237
|
+
if (!Array.isArray(response?.data)) {
|
|
238
|
+
this.#recordUnmapped('thread/items/list response omitted data array', response);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const entries = response.data;
|
|
242
|
+
for (const entry of entries) {
|
|
243
|
+
const object = asObject(entry);
|
|
244
|
+
if (object === undefined) {
|
|
245
|
+
this.#recordUnmapped('thread/items/list entry was not an object', entry);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
this.#mapItem(asObject(object.item) ?? object, true);
|
|
249
|
+
}
|
|
250
|
+
cursor = this.#nextCursor(response, 'thread/items/list', seenCursors);
|
|
251
|
+
} while (cursor !== undefined);
|
|
252
|
+
}
|
|
253
|
+
#nextCursor(response, method, seen) {
|
|
254
|
+
if (response?.nextCursor === undefined || response.nextCursor === null)
|
|
255
|
+
return undefined;
|
|
256
|
+
if (typeof response.nextCursor === 'string' && response.nextCursor.length > 0) {
|
|
257
|
+
if (seen.has(response.nextCursor)) {
|
|
258
|
+
this.#recordUnmapped(`${method} repeated a page cursor`, { method });
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
seen.add(response.nextCursor);
|
|
262
|
+
return response.nextCursor;
|
|
263
|
+
}
|
|
264
|
+
this.#recordUnmapped(`${method} nextCursor expected a non-empty string or null`, response);
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
#startTurn(text) {
|
|
268
|
+
const threadId = this.#threadId;
|
|
269
|
+
if (threadId === null) {
|
|
270
|
+
this.#queuedPrompt = text;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
this.#beginTurnDeadline();
|
|
274
|
+
void this.#requirePeer()
|
|
275
|
+
.request('turn/start', {
|
|
276
|
+
threadId,
|
|
277
|
+
cwd: this.#options.cwd,
|
|
278
|
+
input: [{ type: 'text', text }],
|
|
279
|
+
})
|
|
280
|
+
.then((value) => {
|
|
281
|
+
const turn = asObject(asObject(value)?.turn);
|
|
282
|
+
const id = asString(turn?.id);
|
|
283
|
+
if (id !== undefined && !this.#completedTurns.has(id)) {
|
|
284
|
+
this.#activeTurn = id;
|
|
285
|
+
this.#touchTurnDeadline();
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
.catch((cause) => {
|
|
289
|
+
this.#events.onProtocolError?.(safeError('Codex turn/start failed', cause));
|
|
290
|
+
this.#finishTurn({ stopReason: null, isError: true, raw: 'turn/start failed' });
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
#receive(raw) {
|
|
294
|
+
if (this.#closed)
|
|
295
|
+
return;
|
|
296
|
+
let frame = raw;
|
|
297
|
+
if (typeof raw === 'string') {
|
|
298
|
+
try {
|
|
299
|
+
frame = JSON.parse(raw);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
this.#recordUnmapped(`malformed Codex JSON frame: string(${raw.length})`, {
|
|
303
|
+
type: 'malformed_json',
|
|
304
|
+
description: `string(${raw.length})`,
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const object = asObject(frame);
|
|
310
|
+
if (object === undefined) {
|
|
311
|
+
this.#recordUnmapped('Codex frame was not an object', { description: describe(frame) });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const method = asString(object.method);
|
|
315
|
+
this.#noteTurnActivity(method, object.params);
|
|
316
|
+
const hasId = typeof object.id === 'number' || typeof object.id === 'string';
|
|
317
|
+
if (method !== undefined && hasId) {
|
|
318
|
+
if (APPROVAL_METHODS.has(method)) {
|
|
319
|
+
void this.#handleApproval(object.id, method, object.params);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
this.#recordUnmapped(`unhandled Codex server request: ${method}`, object);
|
|
323
|
+
this.#sendServerReply({
|
|
324
|
+
jsonrpc: '2.0',
|
|
325
|
+
id: object.id,
|
|
326
|
+
error: { code: -32601, message: `method not supported: ${method}` },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (method !== undefined) {
|
|
332
|
+
this.#handleNotification(method, object.params);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
void this.#requirePeer().receive(frame);
|
|
336
|
+
}
|
|
337
|
+
#handleNotification(method, params) {
|
|
338
|
+
const object = asObject(params) ?? {};
|
|
339
|
+
if (!KNOWN_NOTIFICATIONS.has(method)) {
|
|
340
|
+
this.#recordUnmapped(`unmapped Codex notification: ${method}`, { method, params });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
switch (method) {
|
|
344
|
+
case 'thread/started': {
|
|
345
|
+
const id = asString(asObject(object.thread)?.id);
|
|
346
|
+
if (id !== undefined)
|
|
347
|
+
this.#setThreadId(id);
|
|
348
|
+
else
|
|
349
|
+
this.#recordUnmapped('thread/started omitted thread.id', object);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
case 'thread/status/changed': {
|
|
353
|
+
const status = asObject(object.status);
|
|
354
|
+
const flags = Array.isArray(status?.activeFlags) ? status.activeFlags : [];
|
|
355
|
+
if (flags.includes('waitingOnApproval')) {
|
|
356
|
+
this.#emit({ sessionUpdate: 'notice', notice: { level: 'info', message: 'Codex is waiting for permission.' } });
|
|
357
|
+
}
|
|
358
|
+
this.#recordUnmapped('thread/status/changed has no other ACP mapping', object);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
case 'turn/started': {
|
|
362
|
+
const id = asString(asObject(object.turn)?.id);
|
|
363
|
+
if (id !== undefined) {
|
|
364
|
+
this.#activeTurn = id;
|
|
365
|
+
this.#turnInFlight = true;
|
|
366
|
+
}
|
|
367
|
+
else
|
|
368
|
+
this.#recordUnmapped('turn/started omitted turn.id', object);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
case 'turn/completed': {
|
|
372
|
+
const turn = asObject(object.turn) ?? {};
|
|
373
|
+
const id = asString(turn.id) ?? this.#activeTurn;
|
|
374
|
+
const status = asString(turn.status) ?? 'failed';
|
|
375
|
+
if (turn.status === undefined)
|
|
376
|
+
this.#recordUnmapped('turn/completed omitted status', object);
|
|
377
|
+
else if (!['completed', 'interrupted', 'canceled', 'cancelled', 'failed'].includes(status)) {
|
|
378
|
+
this.#recordUnmapped('turn/completed had an unknown status', object);
|
|
379
|
+
}
|
|
380
|
+
if (id === null || id === undefined) {
|
|
381
|
+
this.#recordUnmapped('turn/completed did not contain a turn id', object);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (status === 'completed') {
|
|
385
|
+
this.#finishTurn({ stopReason: 'end_turn', isError: false, raw: status }, id);
|
|
386
|
+
}
|
|
387
|
+
else if (status === 'interrupted' || status === 'canceled' || status === 'cancelled') {
|
|
388
|
+
this.#finishTurn({ stopReason: 'cancelled', isError: false, raw: status }, id);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
this.#finishTurn({ stopReason: null, isError: true, raw: status }, id);
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
case 'item/started':
|
|
396
|
+
case 'item/completed':
|
|
397
|
+
this.#mapItem(asObject(object.item) ?? {}, false, method === 'item/completed');
|
|
398
|
+
return;
|
|
399
|
+
case 'item/agentMessage/delta':
|
|
400
|
+
{
|
|
401
|
+
const itemId = asString(object.itemId);
|
|
402
|
+
if (itemId !== undefined) {
|
|
403
|
+
this.#messageDeltas.add(itemId);
|
|
404
|
+
this.#activeItemIds.add(itemId);
|
|
405
|
+
this.#pauseTurnDeadline();
|
|
406
|
+
}
|
|
407
|
+
else
|
|
408
|
+
this.#recordUnmapped('item/agentMessage/delta omitted itemId', object);
|
|
409
|
+
}
|
|
410
|
+
if (typeof object.delta !== 'string') {
|
|
411
|
+
this.#recordUnmapped('item/agentMessage/delta omitted string delta', object);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.#emit({
|
|
415
|
+
sessionUpdate: 'agent_message_chunk',
|
|
416
|
+
content: { type: 'text', text: object.delta },
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
case 'item/reasoning/summaryTextDelta':
|
|
420
|
+
case 'item/reasoning/textDelta':
|
|
421
|
+
{
|
|
422
|
+
const itemId = asString(object.itemId);
|
|
423
|
+
if (itemId !== undefined) {
|
|
424
|
+
this.#reasoningDeltas.add(itemId);
|
|
425
|
+
this.#activeItemIds.add(itemId);
|
|
426
|
+
this.#pauseTurnDeadline();
|
|
427
|
+
}
|
|
428
|
+
else
|
|
429
|
+
this.#recordUnmapped(`${method} omitted itemId`, object);
|
|
430
|
+
}
|
|
431
|
+
if (typeof object.delta !== 'string') {
|
|
432
|
+
this.#recordUnmapped(`${method} omitted string delta`, object);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
this.#emit({
|
|
436
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
437
|
+
content: { type: 'text', text: object.delta },
|
|
438
|
+
});
|
|
439
|
+
return;
|
|
440
|
+
case 'turn/plan/updated':
|
|
441
|
+
this.#emitPlan(object.plan, 'plan');
|
|
442
|
+
return;
|
|
443
|
+
case 'turn/plan/delta':
|
|
444
|
+
this.#emit({ sessionUpdate: 'plan_update', delta: object.delta ?? object });
|
|
445
|
+
return;
|
|
446
|
+
case 'thread/tokenUsage/updated':
|
|
447
|
+
this.#emitUsage(object.tokenUsage);
|
|
448
|
+
return;
|
|
449
|
+
case 'account/rateLimits/updated':
|
|
450
|
+
this.#emitRateLimit(object.rateLimits);
|
|
451
|
+
return;
|
|
452
|
+
case 'contextCompacted':
|
|
453
|
+
case 'thread/compaction/started':
|
|
454
|
+
this.#emit({ sessionUpdate: 'compaction_update', ...publicFields(object) });
|
|
455
|
+
return;
|
|
456
|
+
default:
|
|
457
|
+
this.#emit({ sessionUpdate: 'subagent_update', ...publicFields(object) });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
#mapItem(item, replay, completed = false) {
|
|
461
|
+
const type = asString(item.type) ?? 'unknown';
|
|
462
|
+
const id = asString(item.id) ?? asString(item.itemId) ?? `codex-${type}-${++this.#itemSequence}`;
|
|
463
|
+
if (!replay) {
|
|
464
|
+
if (completed) {
|
|
465
|
+
this.#activeItemIds.delete(id);
|
|
466
|
+
this.#resumeTurnDeadlineIfReady(true);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
this.#activeItemIds.add(id);
|
|
470
|
+
this.#pauseTurnDeadline();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (completed && !['completed', 'failed', 'declined', 'canceled', 'cancelled', 'exited', 'interrupted'].includes(asString(item.status) ?? '')) {
|
|
474
|
+
this.#recordUnmapped('completed Codex item had an unknown status', item);
|
|
475
|
+
}
|
|
476
|
+
if (type === 'userMessage') {
|
|
477
|
+
const text = textFromContent(item.content);
|
|
478
|
+
if (text.length > 0)
|
|
479
|
+
this.#emit({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text } });
|
|
480
|
+
else
|
|
481
|
+
this.#recordUnmapped('Codex userMessage had no text content', item);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (type === 'agentMessage') {
|
|
485
|
+
if (replay) {
|
|
486
|
+
const text = asString(item.text) ?? '';
|
|
487
|
+
if (text.length > 0)
|
|
488
|
+
this.#emit({ sessionUpdate: 'session_message', content: [{ type: 'text', text }] });
|
|
489
|
+
else
|
|
490
|
+
this.#recordUnmapped('replayed agentMessage had no text', item);
|
|
491
|
+
}
|
|
492
|
+
else if (completed) {
|
|
493
|
+
const hadDeltas = this.#messageDeltas.delete(id);
|
|
494
|
+
const text = asString(item.text) ?? '';
|
|
495
|
+
if (!hadDeltas && text.length > 0) {
|
|
496
|
+
this.#emit({ sessionUpdate: 'session_message', content: [{ type: 'text', text }] });
|
|
497
|
+
}
|
|
498
|
+
else if (!hadDeltas) {
|
|
499
|
+
this.#recordUnmapped('completed agentMessage had no text or deltas', item);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (type === 'reasoning') {
|
|
505
|
+
const summary = Array.isArray(item.summary) ? item.summary.map(textFromContent).join('') : '';
|
|
506
|
+
if (replay) {
|
|
507
|
+
if (summary.length > 0)
|
|
508
|
+
this.#emit({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: summary } });
|
|
509
|
+
else
|
|
510
|
+
this.#recordUnmapped('replayed reasoning item had no summary', item);
|
|
511
|
+
}
|
|
512
|
+
else if (completed) {
|
|
513
|
+
const hadDeltas = this.#reasoningDeltas.delete(id);
|
|
514
|
+
if (!hadDeltas && summary.length > 0) {
|
|
515
|
+
this.#emit({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: summary } });
|
|
516
|
+
}
|
|
517
|
+
else if (!hadDeltas) {
|
|
518
|
+
this.#recordUnmapped('completed reasoning item had no summary or deltas', item);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (type === 'commandExecution' || type === 'execCommand') {
|
|
524
|
+
const command = asString(item.command) ?? '';
|
|
525
|
+
this.#emit({
|
|
526
|
+
sessionUpdate: completed ? 'tool_call_update' : 'tool_call',
|
|
527
|
+
toolCallId: id,
|
|
528
|
+
title: command.length === 0 ? 'Run command' : command,
|
|
529
|
+
kind: 'execute',
|
|
530
|
+
status: completed ? statusOf(item.status, item.exitCode) : 'pending',
|
|
531
|
+
...(command.length === 0 ? {} : { rawInput: { command } }),
|
|
532
|
+
});
|
|
533
|
+
if (completed && asString(item.status) === 'declined') {
|
|
534
|
+
this.#finishTurn({ stopReason: 'cancelled', isError: false, raw: 'declined' }, asString(item.turnId));
|
|
535
|
+
}
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (type === 'fileChange' || type === 'patchApply') {
|
|
539
|
+
if (!completed)
|
|
540
|
+
this.#items.set(id, item);
|
|
541
|
+
const changes = item.changes ?? item.fileChanges ?? item.patch;
|
|
542
|
+
if (changes === undefined)
|
|
543
|
+
this.#recordUnmapped('Codex file-change item omitted changes', item);
|
|
544
|
+
this.#emit({
|
|
545
|
+
sessionUpdate: completed ? 'tool_call_update' : 'tool_call',
|
|
546
|
+
toolCallId: id,
|
|
547
|
+
title: 'Apply file changes',
|
|
548
|
+
kind: 'edit',
|
|
549
|
+
status: completed ? statusOf(item.status) : 'pending',
|
|
550
|
+
...(changes === undefined ? {} : { rawInput: changes }),
|
|
551
|
+
});
|
|
552
|
+
if (completed && asString(item.status) === 'declined') {
|
|
553
|
+
this.#finishTurn({ stopReason: 'cancelled', isError: false, raw: 'declined' }, asString(item.turnId));
|
|
554
|
+
}
|
|
555
|
+
if (completed)
|
|
556
|
+
this.#items.delete(id);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (type === 'mcpToolCall' || type === 'dynamicToolCall') {
|
|
560
|
+
this.#emit({
|
|
561
|
+
sessionUpdate: completed ? 'tool_call_update' : 'tool_call',
|
|
562
|
+
toolCallId: id,
|
|
563
|
+
title: asString(item.tool) ?? asString(item.name) ?? 'Tool call',
|
|
564
|
+
status: completed ? statusOf(item.status) : 'pending',
|
|
565
|
+
...(item.arguments === undefined ? {} : { rawInput: item.arguments }),
|
|
566
|
+
});
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
this.#recordUnmapped(`unmapped Codex item: ${type}`, item);
|
|
570
|
+
}
|
|
571
|
+
async #handleApproval(id, method, value) {
|
|
572
|
+
const params = asObject(value) ?? {};
|
|
573
|
+
const approvalKey = String(id);
|
|
574
|
+
if (this.#pendingApprovals.has(approvalKey)) {
|
|
575
|
+
this.#recordUnmapped('duplicate active Codex approval request id', { method, id });
|
|
576
|
+
// Reuse of an active id is invalid. Cancel and answer the original once;
|
|
577
|
+
// replying separately here would put two responses on the same request id.
|
|
578
|
+
this.#pendingApprovals.get(approvalKey)?.cancel();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
let settle;
|
|
582
|
+
let settled = false;
|
|
583
|
+
const resultPromise = new Promise((resolve) => {
|
|
584
|
+
settle = resolve;
|
|
585
|
+
});
|
|
586
|
+
if (this.#pendingApprovals.size === 0)
|
|
587
|
+
this.#pauseTurnDeadline();
|
|
588
|
+
const finish = (result) => {
|
|
589
|
+
if (settled)
|
|
590
|
+
return;
|
|
591
|
+
settled = true;
|
|
592
|
+
this.#pendingApprovals.delete(approvalKey);
|
|
593
|
+
this.#resumeTurnDeadlineIfReady();
|
|
594
|
+
settle(result);
|
|
595
|
+
};
|
|
596
|
+
this.#pendingApprovals.set(approvalKey, {
|
|
597
|
+
cancel: () => {
|
|
598
|
+
if (settled)
|
|
599
|
+
return;
|
|
600
|
+
settled = true;
|
|
601
|
+
this.#pendingApprovals.delete(approvalKey);
|
|
602
|
+
this.#resumeTurnDeadlineIfReady();
|
|
603
|
+
try {
|
|
604
|
+
// Send before closing the socket. Resolving a promise here would let
|
|
605
|
+
// shutdown close the transport before the continuation runs.
|
|
606
|
+
this.#transport?.send({ jsonrpc: '2.0', id, result: cancelApprovalResult(method) });
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// The peer is already gone; local settlement still releases ACP.
|
|
610
|
+
}
|
|
611
|
+
settle(null);
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
const { request, choices } = this.#permissionRequest(method, params);
|
|
615
|
+
const permissionPromise = this.#events.onRequestPermission?.(request);
|
|
616
|
+
if (permissionPromise === undefined) {
|
|
617
|
+
finish(cancelApprovalResult(method));
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
void permissionPromise
|
|
621
|
+
.then((outcome) => finish(codexDecision(method, outcome, choices)))
|
|
622
|
+
.catch(() => finish(cancelApprovalResult(method)));
|
|
623
|
+
}
|
|
624
|
+
const result = await resultPromise;
|
|
625
|
+
if (result === null)
|
|
626
|
+
return;
|
|
627
|
+
try {
|
|
628
|
+
this.#transport?.send({ jsonrpc: '2.0', id, result });
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Transport death is reported separately and the approval has already
|
|
632
|
+
// been settled locally, so it cannot keep an ACP turn parked.
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
#permissionRequest(method, params) {
|
|
636
|
+
const choices = new Map();
|
|
637
|
+
const options = [];
|
|
638
|
+
const add = (optionId, name, kind, decision, meta) => {
|
|
639
|
+
const option = { optionId, name, kind };
|
|
640
|
+
if (meta !== undefined)
|
|
641
|
+
option._meta = meta;
|
|
642
|
+
choices.set(optionId, { option, decision });
|
|
643
|
+
options.push(option);
|
|
644
|
+
};
|
|
645
|
+
let toolCallId = asString(params.itemId) ?? asString(params.callId) ?? asString(params.approvalId) ??
|
|
646
|
+
`codex-permission-${++this.#approvalSequence}`;
|
|
647
|
+
let toolTitle = 'Codex requests permission';
|
|
648
|
+
let rawInput = undefined;
|
|
649
|
+
let reason = asString(params.reason);
|
|
650
|
+
if (method === 'item/commandExecution/requestApproval') {
|
|
651
|
+
toolTitle = asString(params.command) ?? 'Run command';
|
|
652
|
+
toolCallId = asString(params.itemId) ?? toolCallId;
|
|
653
|
+
rawInput = {
|
|
654
|
+
...(params.command === undefined ? {} : { command: params.command }),
|
|
655
|
+
...(params.cwd === undefined ? {} : { cwd: params.cwd }),
|
|
656
|
+
...(params.commandActions === undefined ? {} : { commandActions: params.commandActions }),
|
|
657
|
+
...(params.networkApprovalContext === undefined ? {} : { networkApprovalContext: params.networkApprovalContext }),
|
|
658
|
+
...(params.proposedNetworkPolicyAmendments === undefined
|
|
659
|
+
? {}
|
|
660
|
+
: { proposedNetworkPolicyAmendments: params.proposedNetworkPolicyAmendments }),
|
|
661
|
+
};
|
|
662
|
+
const amendment = stringArray(params.proposedExecpolicyAmendment);
|
|
663
|
+
const offered = Array.isArray(params.availableDecisions) ? params.availableDecisions : [];
|
|
664
|
+
for (const decision of offered) {
|
|
665
|
+
if (decision === 'accept')
|
|
666
|
+
add('codex-accept', 'Allow once', 'allow_once', 'accept');
|
|
667
|
+
else if (decision === 'acceptForSession') {
|
|
668
|
+
add('codex-accept-session', 'Allow for this session', 'allow_always', 'acceptForSession');
|
|
669
|
+
}
|
|
670
|
+
else if (decision === 'cancel')
|
|
671
|
+
add('codex-cancel', 'Reject', 'reject_once', 'cancel');
|
|
672
|
+
else if (decision === 'decline')
|
|
673
|
+
add('codex-decline', 'Reject and continue', 'reject_once', 'decline');
|
|
674
|
+
else if (isObject(decision) && isObject(decision.acceptWithExecpolicyAmendment)) {
|
|
675
|
+
const argv = stringArray(asObject(decision.acceptWithExecpolicyAmendment)?.execpolicy_amendment) ?? amendment;
|
|
676
|
+
if (argv !== undefined) {
|
|
677
|
+
add('codex-accept-amendment', 'Allow and remember this command', 'allow_always', { acceptWithExecpolicyAmendment: { execpolicy_amendment: argv } }, { [META_EXEC_POLICY_AMENDMENT]: argv });
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
this.#recordUnmapped('Codex execpolicy amendment omitted its argv', { method, params });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else
|
|
684
|
+
this.#recordUnmapped('unmapped Codex command approval decision', { method, decision });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
else if (method === 'item/fileChange/requestApproval') {
|
|
688
|
+
const item = this.#items.get(toolCallId);
|
|
689
|
+
toolTitle = 'Apply file changes';
|
|
690
|
+
rawInput = item?.changes ?? item?.fileChanges ?? { grantRoot: params.grantRoot ?? null };
|
|
691
|
+
add('codex-accept', 'Allow once', 'allow_once', 'accept');
|
|
692
|
+
add('codex-accept-session', 'Allow for this session', 'allow_always', 'acceptForSession');
|
|
693
|
+
add('codex-decline', 'Reject', 'reject_once', 'decline');
|
|
694
|
+
add('codex-cancel', 'Cancel request', 'reject_once', 'cancel');
|
|
695
|
+
}
|
|
696
|
+
else if (method === 'item/permissions/requestApproval') {
|
|
697
|
+
toolTitle = 'Request additional permissions';
|
|
698
|
+
rawInput = params.permissions;
|
|
699
|
+
add('codex-grant-turn', 'Allow for this turn', 'allow_once', { permissions: params.permissions, scope: 'turn' });
|
|
700
|
+
add('codex-grant-session', 'Allow for this session', 'allow_always', {
|
|
701
|
+
permissions: params.permissions,
|
|
702
|
+
scope: 'session',
|
|
703
|
+
});
|
|
704
|
+
add('codex-deny', 'Reject', 'reject_once', { permissions: {}, scope: 'turn' });
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
const isPatch = method === 'applyPatchApproval';
|
|
708
|
+
toolTitle = isPatch ? 'Apply file changes' : asString(params.command) ?? 'Run command';
|
|
709
|
+
toolCallId = asString(params.callId) ?? toolCallId;
|
|
710
|
+
rawInput = isPatch ? params.fileChanges : { command: params.command, cwd: params.cwd };
|
|
711
|
+
add('codex-approve', 'Allow once', 'allow_once', { decision: 'approved' });
|
|
712
|
+
add('codex-approve-session', 'Allow for this session', 'allow_always', { decision: 'approved_for_session' });
|
|
713
|
+
add('codex-deny', 'Reject and continue', 'reject_once', { decision: { denied: { rejection: 'Declined by user' } } });
|
|
714
|
+
add('codex-abort', 'Cancel this turn', 'reject_once', { decision: 'abort' });
|
|
715
|
+
}
|
|
716
|
+
if (options.length === 0) {
|
|
717
|
+
const fallback = method === 'item/permissions/requestApproval'
|
|
718
|
+
? { permissions: {}, scope: 'turn' }
|
|
719
|
+
: method === 'applyPatchApproval' || method === 'execCommandApproval'
|
|
720
|
+
? { decision: 'abort' }
|
|
721
|
+
: 'cancel';
|
|
722
|
+
add('codex-cancel', 'Reject', 'reject_once', fallback);
|
|
723
|
+
this.#recordUnmapped('Codex approval had no recognised available decisions', { method, params });
|
|
724
|
+
}
|
|
725
|
+
const toolCall = {
|
|
726
|
+
toolCallId,
|
|
727
|
+
title: toolTitle,
|
|
728
|
+
status: 'pending',
|
|
729
|
+
...(method.includes('commandExecution') || method === 'execCommandApproval'
|
|
730
|
+
? { kind: 'execute' }
|
|
731
|
+
: method.includes('fileChange') || method === 'applyPatchApproval'
|
|
732
|
+
? { kind: 'edit' }
|
|
733
|
+
: {}),
|
|
734
|
+
...(rawInput === undefined ? {} : { rawInput }),
|
|
735
|
+
};
|
|
736
|
+
const request = {
|
|
737
|
+
toolCall,
|
|
738
|
+
options,
|
|
739
|
+
_meta: { 'particle.academy/codex_permission_method': method, ...(reason === undefined ? {} : { reason }) },
|
|
740
|
+
};
|
|
741
|
+
return { request, choices };
|
|
742
|
+
}
|
|
743
|
+
#emitRateLimit(value) {
|
|
744
|
+
const read = readCodexRateLimit(value);
|
|
745
|
+
if (!read.ok) {
|
|
746
|
+
this.#recordUnmapped(read.reason, { method: 'account/rateLimits' });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
this.#emit(withMeta({
|
|
750
|
+
sessionUpdate: 'notice',
|
|
751
|
+
notice: {
|
|
752
|
+
level: read.limit.ordinaryUsageAllowed === false ? 'warning' : 'info',
|
|
753
|
+
message: codexRateLimitNotice(read.limit),
|
|
754
|
+
},
|
|
755
|
+
}, { [META_RATE_LIMIT]: read.limit }));
|
|
756
|
+
}
|
|
757
|
+
#emitUsage(value) {
|
|
758
|
+
const usage = asObject(value);
|
|
759
|
+
const total = asObject(usage?.total);
|
|
760
|
+
if (total === undefined) {
|
|
761
|
+
this.#recordUnmapped('thread/tokenUsage/updated omitted total', value);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (!isNonNegativeInt(total.inputTokens)) {
|
|
765
|
+
this.#recordUnmapped('thread/tokenUsage/updated total.inputTokens expected a non-negative integer', value);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (!isNonNegativeInt(total.outputTokens)) {
|
|
769
|
+
this.#recordUnmapped('thread/tokenUsage/updated total.outputTokens expected a non-negative integer', value);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (!Number.isSafeInteger(total.inputTokens + total.outputTokens)) {
|
|
773
|
+
this.#recordUnmapped('thread/tokenUsage/updated total token sum exceeds a safe integer', value);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
for (const field of ['cachedInputTokens', 'reasoningOutputTokens']) {
|
|
777
|
+
if (total[field] !== undefined && total[field] !== null && !isNonNegativeInt(total[field])) {
|
|
778
|
+
this.#recordUnmapped(`thread/tokenUsage/updated total.${field} expected a non-negative integer or null`, value);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const contextWindow = typeof usage?.modelContextWindow === 'number' ? usage.modelContextWindow : undefined;
|
|
783
|
+
if (contextWindow === undefined || !Number.isSafeInteger(contextWindow) || contextWindow <= 0) {
|
|
784
|
+
this.#recordUnmapped('thread/tokenUsage/updated modelContextWindow expected a positive safe integer', value);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
this.#emit({
|
|
788
|
+
sessionUpdate: 'usage_update',
|
|
789
|
+
used: total.inputTokens + total.outputTokens,
|
|
790
|
+
size: contextWindow,
|
|
791
|
+
_meta: {
|
|
792
|
+
inputTokens: total.inputTokens,
|
|
793
|
+
outputTokens: total.outputTokens,
|
|
794
|
+
cachedInputTokens: total.cachedInputTokens ?? null,
|
|
795
|
+
reasoningOutputTokens: total.reasoningOutputTokens ?? null,
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
#emitPlan(value, sessionUpdate) {
|
|
800
|
+
if (!Array.isArray(value)) {
|
|
801
|
+
this.#recordUnmapped('turn/plan/updated omitted plan array', value);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const entries = value.map((entry, index) => {
|
|
805
|
+
const object = asObject(entry) ?? {};
|
|
806
|
+
const status = asString(object.status);
|
|
807
|
+
if (status !== 'completed' && status !== 'inProgress' && status !== 'pending') {
|
|
808
|
+
this.#recordUnmapped('turn/plan/updated entry had an unknown status', entry);
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
content: asString(object.step) ?? asString(object.title) ?? `Step ${index + 1}`,
|
|
812
|
+
status: status === 'completed' ? 'completed' : status === 'inProgress' ? 'in_progress' : 'pending',
|
|
813
|
+
};
|
|
814
|
+
});
|
|
815
|
+
this.#emit({ sessionUpdate, entries });
|
|
816
|
+
}
|
|
817
|
+
#setThreadId(id) {
|
|
818
|
+
if (id.length === 0) {
|
|
819
|
+
this.#recordUnmapped('Codex thread id was empty', { thread: { id } });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (this.#threadId !== null && this.#threadId !== id) {
|
|
823
|
+
this.#recordUnmapped('Codex thread id changed during a session', { previous: this.#threadId, current: id });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
this.#threadId = id;
|
|
827
|
+
this.cliSessionId = id;
|
|
828
|
+
if (this.#sessionUpdateSent)
|
|
829
|
+
return;
|
|
830
|
+
this.#sessionUpdateSent = true;
|
|
831
|
+
this.#emit(withMeta({ sessionUpdate: 'notice', notice: { level: 'debug', message: 'Codex session started' } }, { [META_CLI_SESSION_ID]: id }));
|
|
832
|
+
}
|
|
833
|
+
#recordUnmapped(reason, frame) {
|
|
834
|
+
const safeFrame = stripPrivateIdentifiers(frame);
|
|
835
|
+
this.#emit(withMeta({ sessionUpdate: 'notice', notice: { level: 'debug', message: reason } }, { [META_UNMAPPED_FRAME]: { reason, frame: safeFrame } }));
|
|
836
|
+
}
|
|
837
|
+
#emit(update) {
|
|
838
|
+
if (this.#closed)
|
|
839
|
+
return;
|
|
840
|
+
this.#events.onUpdate?.(update);
|
|
841
|
+
}
|
|
842
|
+
#sendServerReply(frame) {
|
|
843
|
+
try {
|
|
844
|
+
this.#transport?.send(frame);
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
this.#events.onProtocolError?.('could not answer Codex server request (transport unavailable)');
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
#finishTurn(outcome, turnId = this.#activeTurn) {
|
|
851
|
+
if (turnId !== null && this.#completedTurns.has(turnId))
|
|
852
|
+
return;
|
|
853
|
+
if (turnId !== null) {
|
|
854
|
+
this.#completedTurns.add(turnId);
|
|
855
|
+
if (this.#completedTurns.size > 64) {
|
|
856
|
+
const oldest = this.#completedTurns.values().next().value;
|
|
857
|
+
if (oldest !== undefined)
|
|
858
|
+
this.#completedTurns.delete(oldest);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (!this.#turnInFlight && this.#activeTurn === null)
|
|
862
|
+
return;
|
|
863
|
+
this.#clearTurnDeadline();
|
|
864
|
+
this.#activeTurn = null;
|
|
865
|
+
this.#turnInFlight = false;
|
|
866
|
+
this.#items.clear();
|
|
867
|
+
this.#activeItemIds.clear();
|
|
868
|
+
this.#messageDeltas.clear();
|
|
869
|
+
this.#reasoningDeltas.clear();
|
|
870
|
+
this.#events.onTurnEnd?.(outcome);
|
|
871
|
+
}
|
|
872
|
+
#shutdown(signal) {
|
|
873
|
+
if (this.#closed)
|
|
874
|
+
return;
|
|
875
|
+
this.#closed = true;
|
|
876
|
+
this.#clearTurnDeadline();
|
|
877
|
+
for (const pending of [...this.#pendingApprovals.values()])
|
|
878
|
+
pending.cancel();
|
|
879
|
+
this.#transport?.close(signal);
|
|
880
|
+
this.#peer?.fail(new Error('Codex driver stopped'));
|
|
881
|
+
this.#events.onExit?.(null, signal);
|
|
882
|
+
}
|
|
883
|
+
#onExit(code, signal) {
|
|
884
|
+
if (this.#closed)
|
|
885
|
+
return;
|
|
886
|
+
this.#closed = true;
|
|
887
|
+
this.#clearTurnDeadline();
|
|
888
|
+
for (const pending of [...this.#pendingApprovals.values()])
|
|
889
|
+
pending.cancel();
|
|
890
|
+
this.#peer?.fail(new Error('Codex app-server exited'));
|
|
891
|
+
this.#events.onExit?.(code, signal);
|
|
892
|
+
}
|
|
893
|
+
#requirePeer() {
|
|
894
|
+
if (this.#peer === null)
|
|
895
|
+
throw new Error('Codex App Server has not started');
|
|
896
|
+
return this.#peer;
|
|
897
|
+
}
|
|
898
|
+
#noteTurnActivity(method, value) {
|
|
899
|
+
if (!this.#turnInFlight)
|
|
900
|
+
return;
|
|
901
|
+
const params = asObject(value) ?? {};
|
|
902
|
+
const item = asObject(params.item);
|
|
903
|
+
const turn = asObject(params.turn);
|
|
904
|
+
const turnId = asString(params.turnId) ?? asString(item?.turnId) ?? asString(turn?.id);
|
|
905
|
+
if (turnId !== undefined) {
|
|
906
|
+
if (this.#activeTurn === null || turnId === this.#activeTurn)
|
|
907
|
+
this.#touchTurnDeadline();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (method === 'thread/status/changed' || method?.startsWith('turn/') || method?.startsWith('item/')) {
|
|
911
|
+
this.#touchTurnDeadline();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
#beginTurnDeadline() {
|
|
915
|
+
this.#turnDeadlineRemainingMs = this.#turnInactivityTimeoutMs;
|
|
916
|
+
this.#scheduleTurnDeadline();
|
|
917
|
+
}
|
|
918
|
+
#touchTurnDeadline() {
|
|
919
|
+
if (!this.#turnInFlight || this.#hasOutstandingWork())
|
|
920
|
+
return;
|
|
921
|
+
this.#turnDeadlineRemainingMs = this.#turnInactivityTimeoutMs;
|
|
922
|
+
this.#scheduleTurnDeadline();
|
|
923
|
+
}
|
|
924
|
+
#pauseTurnDeadline() {
|
|
925
|
+
if (this.#turnDeadlineTimer === null)
|
|
926
|
+
return;
|
|
927
|
+
this.#turnDeadlineRemainingMs = Math.max(0, this.#turnDeadlineExpiresAt - Date.now());
|
|
928
|
+
clearTimeout(this.#turnDeadlineTimer);
|
|
929
|
+
this.#turnDeadlineTimer = null;
|
|
930
|
+
}
|
|
931
|
+
#resumeTurnDeadlineIfReady(resetRemaining = false) {
|
|
932
|
+
if (!this.#turnInFlight || this.#hasOutstandingWork())
|
|
933
|
+
return;
|
|
934
|
+
if (resetRemaining)
|
|
935
|
+
this.#turnDeadlineRemainingMs = this.#turnInactivityTimeoutMs;
|
|
936
|
+
this.#scheduleTurnDeadline();
|
|
937
|
+
}
|
|
938
|
+
#scheduleTurnDeadline() {
|
|
939
|
+
if (!this.#turnInFlight || this.#hasOutstandingWork() || this.#closed)
|
|
940
|
+
return;
|
|
941
|
+
if (this.#turnDeadlineTimer !== null)
|
|
942
|
+
clearTimeout(this.#turnDeadlineTimer);
|
|
943
|
+
const delay = this.#turnDeadlineRemainingMs;
|
|
944
|
+
this.#turnDeadlineExpiresAt = Date.now() + delay;
|
|
945
|
+
this.#turnDeadlineTimer = setTimeout(() => {
|
|
946
|
+
this.#turnDeadlineTimer = null;
|
|
947
|
+
if (!this.#turnInFlight || this.#hasOutstandingWork())
|
|
948
|
+
return;
|
|
949
|
+
const message = `Codex turn inactivity deadline expired after ${this.#turnInactivityTimeoutMs} ms.`;
|
|
950
|
+
this.#emit({ sessionUpdate: 'notice', notice: { level: 'warning', message } });
|
|
951
|
+
this.#recordUnmapped('Codex turn deadline expired', {
|
|
952
|
+
method: 'turn deadline',
|
|
953
|
+
timeoutMs: this.#turnInactivityTimeoutMs,
|
|
954
|
+
});
|
|
955
|
+
this.#finishTurn({ stopReason: null, isError: true, raw: 'turn deadline' });
|
|
956
|
+
}, delay);
|
|
957
|
+
}
|
|
958
|
+
#clearTurnDeadline() {
|
|
959
|
+
if (this.#turnDeadlineTimer !== null)
|
|
960
|
+
clearTimeout(this.#turnDeadlineTimer);
|
|
961
|
+
this.#turnDeadlineTimer = null;
|
|
962
|
+
this.#turnDeadlineRemainingMs = 0;
|
|
963
|
+
this.#turnDeadlineExpiresAt = 0;
|
|
964
|
+
}
|
|
965
|
+
#hasOutstandingWork() {
|
|
966
|
+
return this.#pendingApprovals.size > 0 || this.#activeItemIds.size > 0;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function codexDecision(method, outcome, choices) {
|
|
970
|
+
if (outcome.outcome !== 'selected')
|
|
971
|
+
return cancelApprovalResult(method);
|
|
972
|
+
const choice = choices.get(outcome.optionId);
|
|
973
|
+
if (choice === undefined)
|
|
974
|
+
return cancelApprovalResult(method);
|
|
975
|
+
if (method === 'item/permissions/requestApproval') {
|
|
976
|
+
return isObject(choice.decision) ? choice.decision : { permissions: {}, scope: 'turn' };
|
|
977
|
+
}
|
|
978
|
+
if (method === 'applyPatchApproval' || method === 'execCommandApproval') {
|
|
979
|
+
return isObject(choice.decision) && 'decision' in choice.decision
|
|
980
|
+
? { decision: choice.decision.decision }
|
|
981
|
+
: { decision: choice.decision };
|
|
982
|
+
}
|
|
983
|
+
return { decision: choice.decision };
|
|
984
|
+
}
|
|
985
|
+
function cancelApprovalResult(method) {
|
|
986
|
+
if (method === 'item/permissions/requestApproval')
|
|
987
|
+
return { permissions: {}, scope: 'turn' };
|
|
988
|
+
if (method === 'applyPatchApproval' || method === 'execCommandApproval')
|
|
989
|
+
return { decision: 'abort' };
|
|
990
|
+
return { decision: 'cancel' };
|
|
991
|
+
}
|
|
992
|
+
function isMintedAcpId(id) {
|
|
993
|
+
return /^sess_\d+_\d+$/.test(id);
|
|
994
|
+
}
|
|
995
|
+
function safeError(prefix, cause) {
|
|
996
|
+
if (cause !== null && typeof cause === 'object' && 'code' in cause) {
|
|
997
|
+
const code = cause.code;
|
|
998
|
+
return `${prefix} (RPC code ${typeof code === 'number' ? code : 'unknown'})`;
|
|
999
|
+
}
|
|
1000
|
+
return `${prefix} (${cause instanceof Error ? cause.name : typeof cause})`;
|
|
1001
|
+
}
|
|
1002
|
+
function asObject(value) {
|
|
1003
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1004
|
+
? value
|
|
1005
|
+
: undefined;
|
|
1006
|
+
}
|
|
1007
|
+
function isObject(value) {
|
|
1008
|
+
return asObject(value) !== undefined;
|
|
1009
|
+
}
|
|
1010
|
+
function asString(value) {
|
|
1011
|
+
return typeof value === 'string' ? value : undefined;
|
|
1012
|
+
}
|
|
1013
|
+
function stringArray(value) {
|
|
1014
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))
|
|
1015
|
+
return undefined;
|
|
1016
|
+
return value;
|
|
1017
|
+
}
|
|
1018
|
+
function isNonNegativeInt(value) {
|
|
1019
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
1020
|
+
}
|
|
1021
|
+
function textFromContent(value) {
|
|
1022
|
+
if (typeof value === 'string')
|
|
1023
|
+
return value;
|
|
1024
|
+
if (!Array.isArray(value))
|
|
1025
|
+
return '';
|
|
1026
|
+
return value
|
|
1027
|
+
.map((entry) => {
|
|
1028
|
+
const object = asObject(entry);
|
|
1029
|
+
return asString(object?.text) ?? '';
|
|
1030
|
+
})
|
|
1031
|
+
.join('');
|
|
1032
|
+
}
|
|
1033
|
+
function statusOf(status, exitCode) {
|
|
1034
|
+
if (status === 'completed' || (status === 'exited' && exitCode === 0))
|
|
1035
|
+
return 'completed';
|
|
1036
|
+
if (status === 'failed' || status === 'declined' || (typeof exitCode === 'number' && exitCode !== 0)) {
|
|
1037
|
+
return 'failed';
|
|
1038
|
+
}
|
|
1039
|
+
return 'in_progress';
|
|
1040
|
+
}
|
|
1041
|
+
function publicFields(value) {
|
|
1042
|
+
return stripPrivateIdentifiers(value);
|
|
1043
|
+
}
|
|
1044
|
+
function stripPrivateIdentifiers(value, sensitiveContext = false) {
|
|
1045
|
+
if (Array.isArray(value))
|
|
1046
|
+
return value.map((entry) => stripPrivateIdentifiers(entry, sensitiveContext));
|
|
1047
|
+
const object = asObject(value);
|
|
1048
|
+
if (object === undefined)
|
|
1049
|
+
return value;
|
|
1050
|
+
const result = {};
|
|
1051
|
+
for (const [key, entry] of Object.entries(object)) {
|
|
1052
|
+
const normalized = key.replace(/[_-]/g, '').toLowerCase();
|
|
1053
|
+
if (/(?:account|credit|ratelimit|limit).*id$/.test(normalized) ||
|
|
1054
|
+
(sensitiveContext && normalized === 'id')) {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const childSensitive = sensitiveContext ||
|
|
1058
|
+
normalized === 'account' ||
|
|
1059
|
+
normalized === 'ratelimit' ||
|
|
1060
|
+
normalized === 'ratelimits' ||
|
|
1061
|
+
normalized === 'limit' ||
|
|
1062
|
+
normalized === 'ratelimitresetcredits' ||
|
|
1063
|
+
normalized === 'credit' ||
|
|
1064
|
+
normalized === 'credits';
|
|
1065
|
+
result[key] = stripPrivateIdentifiers(entry, childSensitive);
|
|
1066
|
+
}
|
|
1067
|
+
return result;
|
|
1068
|
+
}
|
|
1069
|
+
function describe(value) {
|
|
1070
|
+
if (value === null)
|
|
1071
|
+
return 'null';
|
|
1072
|
+
if (value === undefined)
|
|
1073
|
+
return 'undefined';
|
|
1074
|
+
if (typeof value === 'string')
|
|
1075
|
+
return `string(${value.length})`;
|
|
1076
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
1077
|
+
return String(value);
|
|
1078
|
+
if (Array.isArray(value))
|
|
1079
|
+
return `array(${value.length})`;
|
|
1080
|
+
return typeof value === 'object' ? 'object' : typeof value;
|
|
1081
|
+
}
|