@agentex/agent 0.0.33 → 0.0.34

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.
@@ -89,6 +89,8 @@ function buildCodexUserInputAnswers(questions, resp) {
89
89
  }
90
90
  return out;
91
91
  }
92
+ const BACKGROUND_TASK_POLL_INTERVAL_MS = 2_000;
93
+ const BACKGROUND_TASK_READ_TIMEOUT_MS = 5_000;
92
94
  // ---------------------------------------------------------------------------
93
95
  // JSON-RPC 2.0 helpers
94
96
  // ---------------------------------------------------------------------------
@@ -116,6 +118,30 @@ function asObj(parent, key) {
116
118
  ? v
117
119
  : {};
118
120
  }
121
+ function obj(value) {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value)
123
+ ? value
124
+ : {};
125
+ }
126
+ function stringArray(value) {
127
+ return Array.isArray(value)
128
+ ? value.filter((entry) => typeof entry === "string" && entry.length > 0)
129
+ : [];
130
+ }
131
+ function codexAgentState(value) {
132
+ const state = obj(value);
133
+ const nativeStatus = str(state, "status");
134
+ const summary = str(state, "message") || null;
135
+ if (nativeStatus === "completed")
136
+ return { terminal: true, status: "completed", summary };
137
+ if (nativeStatus === "errored" || nativeStatus === "notFound") {
138
+ return { terminal: true, status: "failed", summary };
139
+ }
140
+ if (nativeStatus === "interrupted" || nativeStatus === "shutdown") {
141
+ return { terminal: true, status: "stopped", summary };
142
+ }
143
+ return { terminal: false, status: "running", summary };
144
+ }
119
145
  function classifyMessage(msg) {
120
146
  // Detect JSON-RPC by *structure*, not by the `jsonrpc:"2.0"` discriminator.
121
147
  // codex-cli 0.130.0's `app-server` emits responses without the `jsonrpc`
@@ -297,6 +323,10 @@ export class CodexSessionImpl {
297
323
  /** Child-agent lifecycle is informational and never participates in root turn settlement. */
298
324
  _backgroundTasks = new Map();
299
325
  _backgroundTaskIdsByPath = new Map();
326
+ /** One protocol-native thread/read poller per child when Codex does not multiplex its notifications. */
327
+ _backgroundTaskPollers = new Map();
328
+ /** Cancellable retry delay for each poller. Cleared when the session closes. */
329
+ _backgroundTaskPollTimers = new Map();
300
330
  /**
301
331
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
302
332
  * handler invocation; the chain enforces in-order delivery and lets
@@ -364,17 +394,51 @@ export class CodexSessionImpl {
364
394
  proc.on("exit", (code, signal) => {
365
395
  if (this._state !== "closed") {
366
396
  this._state = "closed";
367
- const err = new Error(`Codex process exited unexpectedly (code=${code}, signal=${signal})`);
397
+ const message = `Codex process exited unexpectedly (code=${code}, signal=${signal})`;
398
+ this.finalizeActiveBackgroundTasks("failed", message, {
399
+ method: "process/exit",
400
+ code,
401
+ signal,
402
+ });
403
+ this.cancelBackgroundTaskPollTimers();
404
+ const err = new Error(message);
368
405
  this.rejectAllPending(err);
369
406
  }
370
407
  });
371
408
  proc.on("error", (err) => {
372
409
  if (this._state !== "closed") {
373
410
  this._state = "closed";
411
+ this.finalizeActiveBackgroundTasks("failed", err.message, {
412
+ method: "process/error",
413
+ message: err.message,
414
+ });
415
+ this.cancelBackgroundTaskPollTimers();
374
416
  this.rejectAllPending(err);
375
417
  }
376
418
  });
377
419
  }
420
+ cancelBackgroundTaskPollTimers() {
421
+ for (const pending of this._backgroundTaskPollTimers.values()) {
422
+ clearTimeout(pending.timer);
423
+ pending.resolve();
424
+ }
425
+ this._backgroundTaskPollTimers.clear();
426
+ }
427
+ finalizeActiveBackgroundTasks(status, summary, raw) {
428
+ for (const task of this._backgroundTasks.values()) {
429
+ if (task.terminal)
430
+ continue;
431
+ this.dispatchEvent(this.backgroundTaskEvent(task.taskId, "completed", status, {
432
+ description: task.description,
433
+ summary,
434
+ parentTaskId: task.parentTaskId,
435
+ eventId: this._threadId
436
+ ? `codex:${this._threadId}:background-task:${task.taskId}:session-${status}:completed`
437
+ : null,
438
+ raw,
439
+ }));
440
+ }
441
+ }
378
442
  /** Reject every pending send() Promise and outgoing JSON-RPC call. */
379
443
  rejectAllPending(err) {
380
444
  const pending = this._pendingResults.splice(0);
@@ -488,6 +552,30 @@ export class CodexSessionImpl {
488
552
  this._pendingRpc.set(id, { resolve, reject });
489
553
  });
490
554
  }
555
+ /** A bounded RPC whose pending-map entry is removed if Codex never replies. */
556
+ boundedRpcRequest(method, params, timeoutMs = BACKGROUND_TASK_READ_TIMEOUT_MS) {
557
+ const id = this._nextId++;
558
+ this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
559
+ return new Promise((resolve, reject) => {
560
+ const timer = setTimeout(() => {
561
+ if (!this._pendingRpc.delete(id))
562
+ return;
563
+ reject(new Error(`codex ${method} timed out`));
564
+ }, timeoutMs);
565
+ if (typeof timer.unref === "function")
566
+ timer.unref();
567
+ this._pendingRpc.set(id, {
568
+ resolve: (result) => {
569
+ clearTimeout(timer);
570
+ resolve(result);
571
+ },
572
+ reject: (err) => {
573
+ clearTimeout(timer);
574
+ reject(err);
575
+ },
576
+ });
577
+ });
578
+ }
491
579
  /**
492
580
  * Bounded RPC for experimental, best-effort methods (the `thread/goal/*`
493
581
  * family). An app-server build that doesn't recognize the method may never
@@ -843,6 +931,10 @@ export class CodexSessionImpl {
843
931
  if (this._state === "closed")
844
932
  return;
845
933
  this._state = "closed";
934
+ this.finalizeActiveBackgroundTasks("stopped", "Codex session closed", {
935
+ method: "session/closed",
936
+ });
937
+ this.cancelBackgroundTaskPollTimers();
846
938
  this.rejectAllPending(new Error("Codex session closed"));
847
939
  this.proc.stdin.end();
848
940
  // Grace window before SIGKILL is configurable via ProviderConfig.graceSec
@@ -1046,6 +1138,250 @@ export class CodexSessionImpl {
1046
1138
  }
1047
1139
  return null;
1048
1140
  }
1141
+ backgroundTaskEvent(taskId, phase, status, options) {
1142
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1143
+ return {
1144
+ type: "background_task",
1145
+ taskId,
1146
+ taskType: "subagent",
1147
+ phase,
1148
+ status,
1149
+ description: options.description ?? null,
1150
+ summary: options.summary ?? null,
1151
+ parentTaskId: options.parentTaskId ?? null,
1152
+ timestamp: new Date().toISOString(),
1153
+ providerType: "codex",
1154
+ sessionId: rootThreadId,
1155
+ messageId: null,
1156
+ eventId: options.eventId ?? null,
1157
+ turnId: options.turnId ?? null,
1158
+ parentToolCallId: null,
1159
+ raw: options.raw,
1160
+ };
1161
+ }
1162
+ reconciledBackgroundTaskRaw(threadId, turn) {
1163
+ const error = turn ? str(asObj(turn, "error"), "message") || null : null;
1164
+ return {
1165
+ method: "thread/read",
1166
+ reconciled: true,
1167
+ threadId,
1168
+ turnId: turn ? str(turn, "id") || null : null,
1169
+ status: turn ? str(turn, "status") || null : null,
1170
+ error,
1171
+ };
1172
+ }
1173
+ /**
1174
+ * Codex 0.144+ represents collaboration as a root collabAgentToolCall item.
1175
+ * A completed spawn call only means the child was created. Register every
1176
+ * receiver and then follow the child thread itself for the real terminus.
1177
+ */
1178
+ handleCollabAgentToolCall(item, raw, parentTaskId) {
1179
+ if (this._state === "closed" || !this.ctx.onEvent)
1180
+ return;
1181
+ const tool = str(item, "tool");
1182
+ const states = asObj(item, "agentsStates");
1183
+ const receiverIds = stringArray(item["receiverThreadIds"]);
1184
+ const taskIds = [...new Set([...receiverIds, ...Object.keys(states)])];
1185
+ const canStart = tool === "spawnAgent";
1186
+ const metadataOnly = tool === "resumeAgent" || tool === "sendInput";
1187
+ const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
1188
+ for (const taskId of taskIds) {
1189
+ let previous = this._backgroundTasks.get(taskId);
1190
+ const description = str(item, "prompt") || previous?.description || null;
1191
+ const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
1192
+ // The child thread's own turn/started notification is the only
1193
+ // authoritative reactivation edge. Root resume/send calls can arrive
1194
+ // before or after that child turn, so they only enrich an active task.
1195
+ if (metadataOnly) {
1196
+ if (!previous || previous.terminal)
1197
+ continue;
1198
+ if (description && description !== previous.description) {
1199
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1200
+ description,
1201
+ summary: previous.summary,
1202
+ parentTaskId: resolvedParentTaskId,
1203
+ eventId: this._threadId
1204
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1205
+ : null,
1206
+ raw,
1207
+ }));
1208
+ }
1209
+ continue;
1210
+ }
1211
+ if (!previous && !canStart)
1212
+ continue;
1213
+ if (!canStart && !canStopWithoutState && !(taskId in states))
1214
+ continue;
1215
+ const hasState = taskId in states;
1216
+ const state = !hasState && tool === "closeAgent" && str(item, "status") === "completed"
1217
+ ? { terminal: true, status: "stopped", summary: null }
1218
+ : codexAgentState(states[taskId]);
1219
+ if (!previous) {
1220
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "started", "running", {
1221
+ description,
1222
+ summary: null,
1223
+ parentTaskId: resolvedParentTaskId,
1224
+ eventId: this._threadId
1225
+ ? `codex:${this._threadId}:background-task:${taskId}:started`
1226
+ : null,
1227
+ raw,
1228
+ }));
1229
+ previous = this._backgroundTasks.get(taskId);
1230
+ }
1231
+ else if (previous.terminal) {
1232
+ continue;
1233
+ }
1234
+ else if (description && description !== previous.description) {
1235
+ // thread/started can beat the richer root collab item. Preserve one
1236
+ // start edge, then publish the prompt as ordinary progress metadata.
1237
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1238
+ description,
1239
+ summary: previous.summary,
1240
+ parentTaskId: resolvedParentTaskId,
1241
+ eventId: this._threadId
1242
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1243
+ : null,
1244
+ raw,
1245
+ }));
1246
+ }
1247
+ if (state.terminal) {
1248
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", state.status, {
1249
+ description,
1250
+ summary: state.summary,
1251
+ parentTaskId: resolvedParentTaskId,
1252
+ eventId: this._threadId
1253
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "state"}:completed`
1254
+ : null,
1255
+ raw,
1256
+ }));
1257
+ }
1258
+ else {
1259
+ this.startBackgroundTaskPoller(taskId);
1260
+ }
1261
+ }
1262
+ }
1263
+ waitForBackgroundTaskPoll(pollKey) {
1264
+ if (this._state === "closed")
1265
+ return Promise.resolve();
1266
+ return new Promise((resolve) => {
1267
+ const timer = setTimeout(() => {
1268
+ this._backgroundTaskPollTimers.delete(pollKey);
1269
+ resolve();
1270
+ }, BACKGROUND_TASK_POLL_INTERVAL_MS);
1271
+ if (typeof timer.unref === "function")
1272
+ timer.unref();
1273
+ this._backgroundTaskPollTimers.set(pollKey, { timer, resolve });
1274
+ });
1275
+ }
1276
+ startBackgroundTaskPoller(taskId) {
1277
+ if (this._state === "closed")
1278
+ return;
1279
+ const task = this._backgroundTasks.get(taskId);
1280
+ if (!task || task.terminal)
1281
+ return;
1282
+ const existing = this._backgroundTaskPollers.get(taskId);
1283
+ if (existing?.generation === task.generation)
1284
+ return;
1285
+ const generation = task.generation;
1286
+ const pollKey = `${taskId}:${generation}`;
1287
+ let poller;
1288
+ poller = this.pollBackgroundTask(taskId, generation, pollKey).finally(() => {
1289
+ if (this._backgroundTaskPollers.get(taskId)?.promise === poller) {
1290
+ this._backgroundTaskPollers.delete(taskId);
1291
+ }
1292
+ const pending = this._backgroundTaskPollTimers.get(pollKey);
1293
+ if (pending)
1294
+ clearTimeout(pending.timer);
1295
+ this._backgroundTaskPollTimers.delete(pollKey);
1296
+ });
1297
+ this._backgroundTaskPollers.set(taskId, { generation, promise: poller });
1298
+ }
1299
+ async pollBackgroundTask(taskId, generation, pollKey) {
1300
+ while (this._state !== "closed") {
1301
+ const task = this._backgroundTasks.get(taskId);
1302
+ if (!task || task.generation !== generation || task.terminal)
1303
+ return;
1304
+ try {
1305
+ const response = await this.boundedRpcRequest("thread/read", {
1306
+ threadId: taskId,
1307
+ includeTurns: true,
1308
+ });
1309
+ if (this.observeBackgroundTaskThread(response, taskId, generation))
1310
+ return;
1311
+ }
1312
+ catch {
1313
+ // A child can briefly be pendingInit before thread/read can load it.
1314
+ }
1315
+ const current = this._backgroundTasks.get(taskId);
1316
+ if (!current || current.generation !== generation || current.terminal)
1317
+ return;
1318
+ await this.waitForBackgroundTaskPoll(pollKey);
1319
+ }
1320
+ }
1321
+ /** Return true once thread/read proves that the child's latest turn ended. */
1322
+ observeBackgroundTaskThread(response, taskId, generation) {
1323
+ const task = this._backgroundTasks.get(taskId);
1324
+ if (!task || task.generation !== generation || task.terminal)
1325
+ return true;
1326
+ const thread = asObj(response, "thread");
1327
+ const turns = Array.isArray(thread["turns"])
1328
+ ? thread["turns"].map(obj)
1329
+ : [];
1330
+ const latestTurn = turns[turns.length - 1];
1331
+ if (!latestTurn) {
1332
+ const threadStatus = str(asObj(thread, "status"), "type");
1333
+ if (threadStatus !== "systemError")
1334
+ return false;
1335
+ const failureTurn = { status: "systemError", error: { message: "Subagent failed to initialize" } };
1336
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", "failed", {
1337
+ description: task.description,
1338
+ summary: "Subagent failed to initialize",
1339
+ parentTaskId: task.parentTaskId,
1340
+ eventId: this._threadId
1341
+ ? `codex:${this._threadId}:background-task:${taskId}:system-error:completed`
1342
+ : null,
1343
+ raw: this.reconciledBackgroundTaskRaw(taskId, failureTurn),
1344
+ }));
1345
+ return true;
1346
+ }
1347
+ const nativeStatus = str(latestTurn, "status");
1348
+ if (nativeStatus === "inProgress" || !nativeStatus)
1349
+ return false;
1350
+ const turnId = str(latestTurn, "id") || null;
1351
+ // The child turn/started notification is authoritative for a reactivated
1352
+ // generation. thread/read can briefly lag and still end at an older turn.
1353
+ if (task.activeTurnId && turnId !== task.activeTurnId)
1354
+ return false;
1355
+ const items = Array.isArray(latestTurn["items"])
1356
+ ? latestTurn["items"].map(obj)
1357
+ : [];
1358
+ let summary = null;
1359
+ for (let index = items.length - 1; index >= 0; index--) {
1360
+ const item = items[index];
1361
+ if (str(item, "type") !== "agentMessage")
1362
+ continue;
1363
+ summary = this.agentMessageText(item);
1364
+ if (summary)
1365
+ break;
1366
+ }
1367
+ const errorMessage = str(asObj(latestTurn, "error"), "message") || null;
1368
+ const status = nativeStatus === "failed"
1369
+ ? "failed"
1370
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1371
+ ? "stopped"
1372
+ : "completed";
1373
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", status, {
1374
+ description: task.description,
1375
+ summary: summary ?? errorMessage,
1376
+ parentTaskId: task.parentTaskId,
1377
+ turnId,
1378
+ eventId: this._threadId && turnId
1379
+ ? `codex:${this._threadId}:background-task:${taskId}:${turnId}:completed`
1380
+ : null,
1381
+ raw: this.reconciledBackgroundTaskRaw(taskId, latestTurn),
1382
+ }));
1383
+ return true;
1384
+ }
1049
1385
  /**
1050
1386
  * Maintain just enough child metadata to turn a later foreign-thread
1051
1387
  * terminal notification into one provider-neutral task event. This reducer
@@ -1060,6 +1396,10 @@ export class CodexSessionImpl {
1060
1396
  // explicitly reactivates the record below in handleForeignNotification.
1061
1397
  if (previous?.terminal)
1062
1398
  return false;
1399
+ // collabAgentToolCall and child thread/started can both announce the same
1400
+ // spawn. The first edge is sufficient and gives hosts one durable start.
1401
+ if (previous && event.phase === "started")
1402
+ return false;
1063
1403
  const description = event.description ?? previous?.description ?? null;
1064
1404
  const summary = event.summary ?? previous?.summary ?? null;
1065
1405
  const parentTaskId = event.parentTaskId
@@ -1074,6 +1414,10 @@ export class CodexSessionImpl {
1074
1414
  summary,
1075
1415
  parentTaskId,
1076
1416
  terminal: event.phase === "completed",
1417
+ generation: previous?.generation ?? 0,
1418
+ activeTurnId: event.phase === "completed"
1419
+ ? null
1420
+ : event.turnId ?? previous?.activeTurnId ?? null,
1077
1421
  });
1078
1422
  if (description)
1079
1423
  this._backgroundTaskIdsByPath.set(description, event.taskId);
@@ -1128,6 +1472,7 @@ export class CodexSessionImpl {
1128
1472
  parentToolCallId: null,
1129
1473
  raw: parseJson(rawLine) ?? params,
1130
1474
  });
1475
+ this.startBackgroundTaskPoller(childThreadId);
1131
1476
  return;
1132
1477
  }
1133
1478
  const task = this._backgroundTasks.get(childThreadId);
@@ -1138,6 +1483,7 @@ export class CodexSessionImpl {
1138
1483
  return;
1139
1484
  task.terminal = false;
1140
1485
  task.summary = null;
1486
+ task.generation += 1;
1141
1487
  const turn = asObj(params, "turn");
1142
1488
  const turnId = str(turn, "id") || str(params, "turnId") || null;
1143
1489
  const rootThreadId = this._threadId ?? this._expectedThreadId;
@@ -1161,11 +1507,16 @@ export class CodexSessionImpl {
1161
1507
  parentToolCallId: null,
1162
1508
  raw: parseJson(rawLine) ?? params,
1163
1509
  });
1510
+ this.startBackgroundTaskPoller(childThreadId);
1164
1511
  return;
1165
1512
  }
1166
1513
  if (method === "item/completed") {
1167
1514
  const item = asObj(params, "item");
1168
1515
  const itemType = str(item, "type");
1516
+ if (itemType === "collabAgentToolCall") {
1517
+ this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, childThreadId);
1518
+ return;
1519
+ }
1169
1520
  if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1170
1521
  const summary = this.agentMessageText(item);
1171
1522
  if (summary)
@@ -1205,10 +1556,19 @@ export class CodexSessionImpl {
1205
1556
  : null,
1206
1557
  turnId,
1207
1558
  parentToolCallId: null,
1208
- raw: parseJson(rawLine) ?? params,
1559
+ raw: {
1560
+ method,
1561
+ reconciled: false,
1562
+ threadId: childThreadId,
1563
+ turnId,
1564
+ status: nativeStatus || null,
1565
+ error: errorMessage || null,
1566
+ },
1209
1567
  });
1210
1568
  }
1211
1569
  handleNotification(method, params, rawLine) {
1570
+ if (this._state === "closed")
1571
+ return;
1212
1572
  // codex/event — legacy wrapper
1213
1573
  if (method === "codex/event") {
1214
1574
  const innerMsg = str(params, "msg");
@@ -1251,6 +1611,11 @@ export class CodexSessionImpl {
1251
1611
  if (method === "item/completed") {
1252
1612
  this._state = "thinking";
1253
1613
  this.extractSummaryFromItem(params);
1614
+ const item = asObj(params, "item");
1615
+ if (str(item, "type") === "collabAgentToolCall") {
1616
+ this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null);
1617
+ return;
1618
+ }
1254
1619
  this.emitStreamEvent(rawLine);
1255
1620
  return;
1256
1621
  }
@@ -1286,6 +1651,8 @@ export class CodexSessionImpl {
1286
1651
  // Legacy event handling (NDJSON events with `type` field)
1287
1652
  // -------------------------------------------------------------------------
1288
1653
  handleLegacyEvent(event, rawLine) {
1654
+ if (this._state === "closed")
1655
+ return;
1289
1656
  const type = str(event, "type");
1290
1657
  const eventThreadId = str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
1291
1658
  // Older NDJSON-shaped events can also carry explicit thread scope. Keep