@agentex/agent 0.0.33 → 0.0.35
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/CHANGELOG.md +204 -0
- package/README.md +2 -1
- package/dist/providers/claude/discovery.d.ts +64 -0
- package/dist/providers/claude/discovery.d.ts.map +1 -0
- package/dist/providers/claude/discovery.js +147 -0
- package/dist/providers/claude/discovery.js.map +1 -0
- package/dist/providers/claude/effort.d.ts +20 -0
- package/dist/providers/claude/effort.d.ts.map +1 -0
- package/dist/providers/claude/effort.js +32 -0
- package/dist/providers/claude/effort.js.map +1 -0
- package/dist/providers/claude/execute.d.ts.map +1 -1
- package/dist/providers/claude/execute.js +2 -1
- package/dist/providers/claude/execute.js.map +1 -1
- package/dist/providers/claude/index.d.ts.map +1 -1
- package/dist/providers/claude/index.js +2 -1
- package/dist/providers/claude/index.js.map +1 -1
- package/dist/providers/claude/parse.d.ts.map +1 -1
- package/dist/providers/claude/parse.js +26 -4
- package/dist/providers/claude/parse.js.map +1 -1
- package/dist/providers/claude/session.d.ts.map +1 -1
- package/dist/providers/claude/session.js +2 -1
- package/dist/providers/claude/session.js.map +1 -1
- package/dist/providers/codex/discovery.d.ts +20 -0
- package/dist/providers/codex/discovery.d.ts.map +1 -0
- package/dist/providers/codex/discovery.js +76 -0
- package/dist/providers/codex/discovery.js.map +1 -0
- package/dist/providers/codex/execute.d.ts.map +1 -1
- package/dist/providers/codex/execute.js +45 -3
- package/dist/providers/codex/execute.js.map +1 -1
- package/dist/providers/codex/index.d.ts.map +1 -1
- package/dist/providers/codex/index.js +2 -1
- package/dist/providers/codex/index.js.map +1 -1
- package/dist/providers/codex/parse.d.ts +14 -6
- package/dist/providers/codex/parse.d.ts.map +1 -1
- package/dist/providers/codex/parse.js +83 -1
- package/dist/providers/codex/parse.js.map +1 -1
- package/dist/providers/codex/session.d.ts +50 -1
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +611 -46
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/providers/cursor/discovery.d.ts.map +1 -1
- package/dist/providers/cursor/discovery.js +4 -0
- package/dist/providers/cursor/discovery.js.map +1 -1
- package/dist/providers/opencode/discovery.d.ts.map +1 -1
- package/dist/providers/opencode/discovery.js +11 -8
- package/dist/providers/opencode/discovery.js.map +1 -1
- package/dist/providers/opencode/event-parse.d.ts +23 -0
- package/dist/providers/opencode/event-parse.d.ts.map +1 -1
- package/dist/providers/opencode/event-parse.js +44 -0
- package/dist/providers/opencode/event-parse.js.map +1 -1
- package/dist/providers/opencode/history.d.ts.map +1 -1
- package/dist/providers/opencode/history.js +25 -4
- package/dist/providers/opencode/history.js.map +1 -1
- package/dist/providers/opencode/http-session.d.ts.map +1 -1
- package/dist/providers/opencode/http-session.js +59 -8
- package/dist/providers/opencode/http-session.js.map +1 -1
- package/dist/types.d.ts +19 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/model-cache.d.ts +33 -0
- package/dist/utils/model-cache.d.ts.map +1 -0
- package/dist/utils/model-cache.js +57 -0
- package/dist/utils/model-cache.js.map +1 -0
- package/package.json +1 -1
- package/src/providers/claude/discovery.ts +215 -0
- package/src/providers/claude/effort.ts +34 -0
- package/src/providers/claude/execute.ts +2 -1
- package/src/providers/claude/index.ts +2 -1
- package/src/providers/claude/parse.ts +27 -5
- package/src/providers/claude/session.ts +2 -1
- package/src/providers/codex/discovery.ts +91 -0
- package/src/providers/codex/execute.ts +42 -3
- package/src/providers/codex/index.ts +2 -1
- package/src/providers/codex/parse.ts +95 -3
- package/src/providers/codex/session.ts +691 -48
- package/src/providers/cursor/discovery.ts +5 -0
- package/src/providers/opencode/discovery.ts +10 -7
- package/src/providers/opencode/event-parse.ts +63 -0
- package/src/providers/opencode/history.ts +32 -4
- package/src/providers/opencode/http-session.ts +59 -8
- package/src/types.ts +19 -5
- package/src/utils/model-cache.ts +67 -0
|
@@ -9,7 +9,7 @@ import { translateEndpoint } from "../../utils/endpoint.js";
|
|
|
9
9
|
import { injectWorkspaceSkills } from "../../utils/skills.js";
|
|
10
10
|
import { resolveInstructions } from "../../utils/instructions.js";
|
|
11
11
|
import { createToolNameTracker } from "../../utils/tool-names.js";
|
|
12
|
-
import {
|
|
12
|
+
import { parseCodexStreamLines } from "./parse.js";
|
|
13
13
|
import { withPlanModePreamble } from "./plan-mode.js";
|
|
14
14
|
import { scanCodexSessionUsage } from "./usage-scanner.js";
|
|
15
15
|
import { codexSessionCodec } from "./codec.js";
|
|
@@ -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`
|
|
@@ -279,6 +305,8 @@ export class CodexSessionImpl {
|
|
|
279
305
|
*/
|
|
280
306
|
_activeTurnId = null;
|
|
281
307
|
_activeTurnReady = null;
|
|
308
|
+
/** Incremented per turn latch. Never reused, so staleness is decidable. */
|
|
309
|
+
_turnGeneration = 0;
|
|
282
310
|
/** Successful repeated interrupts coalesce until the terminal notification. */
|
|
283
311
|
_interruptPromise = null;
|
|
284
312
|
/** Prevents a late interrupt request after the terminal frame was observed. */
|
|
@@ -297,6 +325,10 @@ export class CodexSessionImpl {
|
|
|
297
325
|
/** Child-agent lifecycle is informational and never participates in root turn settlement. */
|
|
298
326
|
_backgroundTasks = new Map();
|
|
299
327
|
_backgroundTaskIdsByPath = new Map();
|
|
328
|
+
/** One protocol-native thread/read poller per child when Codex does not multiplex its notifications. */
|
|
329
|
+
_backgroundTaskPollers = new Map();
|
|
330
|
+
/** Cancellable retry delay for each poller. Cleared when the session closes. */
|
|
331
|
+
_backgroundTaskPollTimers = new Map();
|
|
300
332
|
/**
|
|
301
333
|
* Serial dispatch chain for `onEvent`. Each dispatched event appends a
|
|
302
334
|
* handler invocation; the chain enforces in-order delivery and lets
|
|
@@ -364,17 +396,66 @@ export class CodexSessionImpl {
|
|
|
364
396
|
proc.on("exit", (code, signal) => {
|
|
365
397
|
if (this._state !== "closed") {
|
|
366
398
|
this._state = "closed";
|
|
367
|
-
const
|
|
399
|
+
const message = `Codex process exited unexpectedly (code=${code}, signal=${signal})`;
|
|
400
|
+
this.finalizeActiveBackgroundTasks("failed", message, {
|
|
401
|
+
method: "process/exit",
|
|
402
|
+
code,
|
|
403
|
+
signal,
|
|
404
|
+
});
|
|
405
|
+
this.cancelBackgroundTaskPollTimers();
|
|
406
|
+
const err = new Error(message);
|
|
368
407
|
this.rejectAllPending(err);
|
|
369
408
|
}
|
|
370
409
|
});
|
|
371
410
|
proc.on("error", (err) => {
|
|
372
411
|
if (this._state !== "closed") {
|
|
373
412
|
this._state = "closed";
|
|
413
|
+
this.finalizeActiveBackgroundTasks("failed", err.message, {
|
|
414
|
+
method: "process/error",
|
|
415
|
+
message: err.message,
|
|
416
|
+
});
|
|
417
|
+
this.cancelBackgroundTaskPollTimers();
|
|
374
418
|
this.rejectAllPending(err);
|
|
375
419
|
}
|
|
376
420
|
});
|
|
377
421
|
}
|
|
422
|
+
cancelBackgroundTaskPollTimers() {
|
|
423
|
+
for (const pending of this._backgroundTaskPollTimers.values()) {
|
|
424
|
+
clearTimeout(pending.timer);
|
|
425
|
+
pending.resolve();
|
|
426
|
+
}
|
|
427
|
+
this._backgroundTaskPollTimers.clear();
|
|
428
|
+
}
|
|
429
|
+
finalizeActiveBackgroundTasks(status, summary, raw) {
|
|
430
|
+
for (const task of this._backgroundTasks.values()) {
|
|
431
|
+
if (task.terminal)
|
|
432
|
+
continue;
|
|
433
|
+
this.dispatchEvent(this.backgroundTaskEvent(task.taskId, "completed", status, {
|
|
434
|
+
description: task.description,
|
|
435
|
+
summary,
|
|
436
|
+
parentTaskId: task.parentTaskId,
|
|
437
|
+
eventId: this._threadId
|
|
438
|
+
? `codex:${this._threadId}:background-task:${task.taskId}:session-${status}:completed`
|
|
439
|
+
: null,
|
|
440
|
+
raw,
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Remove and return the pending sends belonging to one turn generation.
|
|
446
|
+
*
|
|
447
|
+
* `undefined` matches entries with no generation, which is how tests that
|
|
448
|
+
* drive the session without `send()` enqueue resolvers.
|
|
449
|
+
*/
|
|
450
|
+
takePendingResults(generation) {
|
|
451
|
+
const mine = [];
|
|
452
|
+
const others = [];
|
|
453
|
+
for (const entry of this._pendingResults) {
|
|
454
|
+
(entry.generation === generation ? mine : others).push(entry);
|
|
455
|
+
}
|
|
456
|
+
this._pendingResults = others;
|
|
457
|
+
return mine;
|
|
458
|
+
}
|
|
378
459
|
/** Reject every pending send() Promise and outgoing JSON-RPC call. */
|
|
379
460
|
rejectAllPending(err) {
|
|
380
461
|
const pending = this._pendingResults.splice(0);
|
|
@@ -408,6 +489,7 @@ export class CodexSessionImpl {
|
|
|
408
489
|
resolve: resolveFn,
|
|
409
490
|
reject: rejectFn,
|
|
410
491
|
settled: false,
|
|
492
|
+
generation: ++this._turnGeneration,
|
|
411
493
|
};
|
|
412
494
|
this._activeTurnId = null;
|
|
413
495
|
this._activeTurnReady = ready;
|
|
@@ -433,9 +515,17 @@ export class CodexSessionImpl {
|
|
|
433
515
|
expected.settled = true;
|
|
434
516
|
expected.reject(err);
|
|
435
517
|
}
|
|
436
|
-
/**
|
|
437
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Clear the current turn and release an interrupt waiting for its id.
|
|
520
|
+
*
|
|
521
|
+
* `expected` fences the clear against a turn that started after the one being
|
|
522
|
+
* torn down. Without it, a late teardown nulls the latch a newer, running
|
|
523
|
+
* turn is relying on, which is the same silent-Stop failure by another route.
|
|
524
|
+
*/
|
|
525
|
+
clearActiveTurn(err, expected) {
|
|
438
526
|
const ready = this._activeTurnReady;
|
|
527
|
+
if (expected && ready !== expected)
|
|
528
|
+
return;
|
|
439
529
|
if (ready && !ready.settled) {
|
|
440
530
|
ready.settled = true;
|
|
441
531
|
if (err)
|
|
@@ -488,6 +578,30 @@ export class CodexSessionImpl {
|
|
|
488
578
|
this._pendingRpc.set(id, { resolve, reject });
|
|
489
579
|
});
|
|
490
580
|
}
|
|
581
|
+
/** A bounded RPC whose pending-map entry is removed if Codex never replies. */
|
|
582
|
+
boundedRpcRequest(method, params, timeoutMs = BACKGROUND_TASK_READ_TIMEOUT_MS) {
|
|
583
|
+
const id = this._nextId++;
|
|
584
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
585
|
+
return new Promise((resolve, reject) => {
|
|
586
|
+
const timer = setTimeout(() => {
|
|
587
|
+
if (!this._pendingRpc.delete(id))
|
|
588
|
+
return;
|
|
589
|
+
reject(new Error(`codex ${method} timed out`));
|
|
590
|
+
}, timeoutMs);
|
|
591
|
+
if (typeof timer.unref === "function")
|
|
592
|
+
timer.unref();
|
|
593
|
+
this._pendingRpc.set(id, {
|
|
594
|
+
resolve: (result) => {
|
|
595
|
+
clearTimeout(timer);
|
|
596
|
+
resolve(result);
|
|
597
|
+
},
|
|
598
|
+
reject: (err) => {
|
|
599
|
+
clearTimeout(timer);
|
|
600
|
+
reject(err);
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
}
|
|
491
605
|
/**
|
|
492
606
|
* Bounded RPC for experimental, best-effort methods (the `thread/goal/*`
|
|
493
607
|
* family). An app-server build that doesn't recognize the method may never
|
|
@@ -616,9 +730,17 @@ export class CodexSessionImpl {
|
|
|
616
730
|
// and pass through. If the second `turn/start` lands during the first
|
|
617
731
|
// turn, the per-turn accumulators continue collecting until the result
|
|
618
732
|
// event fires; the result then drains all pending resolvers.
|
|
733
|
+
// Joining the active turn requires that it still be active. The latch
|
|
734
|
+
// survives the terminal frame until deliverTurnResult() clears it, and that
|
|
735
|
+
// gap is real time: it spans `await this._eventChain` (a host persisting
|
|
736
|
+
// events) and, when turn/completed carried no usage, an on-disk usage scan.
|
|
737
|
+
// A send landing in that gap belongs to a NEW turn — joining the dead one
|
|
738
|
+
// would leave it with no interrupt latch and no turn/start capture, so Stop
|
|
739
|
+
// would find nothing to interrupt and return as if it had succeeded.
|
|
619
740
|
const existingTurnReady = this._activeTurnReady;
|
|
620
|
-
const
|
|
621
|
-
const
|
|
741
|
+
const canJoinActiveTurn = existingTurnReady !== null && !this._turnTerminalObserved;
|
|
742
|
+
const isTurnLeader = !canJoinActiveTurn;
|
|
743
|
+
const turnReady = canJoinActiveTurn ? existingTurnReady : this.beginActiveTurn();
|
|
622
744
|
if (this._state === "idle") {
|
|
623
745
|
this._state = "thinking";
|
|
624
746
|
this._turnStartedAt = new Date();
|
|
@@ -648,7 +770,11 @@ export class CodexSessionImpl {
|
|
|
648
770
|
resolveFn = resolve;
|
|
649
771
|
rejectFn = reject;
|
|
650
772
|
});
|
|
651
|
-
const entry = {
|
|
773
|
+
const entry = {
|
|
774
|
+
resolve: resolveFn,
|
|
775
|
+
reject: rejectFn,
|
|
776
|
+
generation: turnReady.generation,
|
|
777
|
+
};
|
|
652
778
|
this._pendingResults.push(entry);
|
|
653
779
|
// Track the in-flight turn so drain() can await it; drop it on settle.
|
|
654
780
|
this._inFlight.add(result);
|
|
@@ -843,6 +969,14 @@ export class CodexSessionImpl {
|
|
|
843
969
|
if (this._state === "closed")
|
|
844
970
|
return;
|
|
845
971
|
this._state = "closed";
|
|
972
|
+
this.finalizeActiveBackgroundTasks("stopped", "Codex session closed", {
|
|
973
|
+
method: "session/closed",
|
|
974
|
+
});
|
|
975
|
+
this.cancelBackgroundTaskPollTimers();
|
|
976
|
+
// finalizeActiveBackgroundTasks above has already emitted the terminal
|
|
977
|
+
// edges, so nothing downstream needs these records any more.
|
|
978
|
+
this._backgroundTasks.clear();
|
|
979
|
+
this._backgroundTaskIdsByPath.clear();
|
|
846
980
|
this.rejectAllPending(new Error("Codex session closed"));
|
|
847
981
|
this.proc.stdin.end();
|
|
848
982
|
// Grace window before SIGKILL is configurable via ProviderConfig.graceSec
|
|
@@ -925,13 +1059,22 @@ export class CodexSessionImpl {
|
|
|
925
1059
|
method === "item/fileChange/requestApproval") {
|
|
926
1060
|
void this.handleApproval(id, method, params);
|
|
927
1061
|
}
|
|
1062
|
+
else if (method === "item/permissions/requestApproval") {
|
|
1063
|
+
void this.handlePermissionsApproval(id, params);
|
|
1064
|
+
}
|
|
928
1065
|
else if (method === "item/tool/requestUserInput" ||
|
|
929
1066
|
method === "tool/requestUserInput") {
|
|
930
1067
|
// `tool/requestUserInput` is the legacy method name on older codex builds.
|
|
931
1068
|
void this.handleUserInputRequest(id, params);
|
|
932
1069
|
}
|
|
933
1070
|
else {
|
|
934
|
-
// Unknown server request
|
|
1071
|
+
// Unknown server request. `{}` is a best-effort reply, NOT a guarantee
|
|
1072
|
+
// that the turn proceeds: every server request the protocol defines but
|
|
1073
|
+
// this file does not handle declares required response fields, so an
|
|
1074
|
+
// empty result is schema-invalid for all of them. A JSON-RPC error reply
|
|
1075
|
+
// would be the honest answer, but `rpcResponse` has no error path and
|
|
1076
|
+
// introducing one changes behavior (an error can abort a turn where this
|
|
1077
|
+
// limps along), so that is a deliberate follow-up rather than a fix here.
|
|
935
1078
|
this.rpcResponse(id, {});
|
|
936
1079
|
}
|
|
937
1080
|
}
|
|
@@ -947,13 +1090,24 @@ export class CodexSessionImpl {
|
|
|
947
1090
|
this._state = this._pendingResults.length > 0 ? "thinking" : "idle";
|
|
948
1091
|
}
|
|
949
1092
|
async handleApproval(id, method, params) {
|
|
950
|
-
|
|
1093
|
+
// A child thread's approval still has to be answered — somebody must decide
|
|
1094
|
+
// whether that subagent may run its command — but it is not the root turn's
|
|
1095
|
+
// business. Driving root state from it would show the session as blocked on
|
|
1096
|
+
// approval when the root turn is running fine, and `restoreStateAfter` would
|
|
1097
|
+
// then recompute root state from a child's lifecycle. The protocol makes
|
|
1098
|
+
// `threadId` mandatory on every approval, so the attribution is free.
|
|
1099
|
+
const requestThreadId = str(params, "threadId") || null;
|
|
1100
|
+
const foreign = this.isForeignThread(requestThreadId);
|
|
1101
|
+
if (!foreign)
|
|
1102
|
+
this._state = "waiting_for_approval";
|
|
1103
|
+
const restore = () => { if (!foreign)
|
|
1104
|
+
this.restoreStateAfter("waiting_for_approval"); };
|
|
951
1105
|
// Codex's app-server expects `{ decision: "accept" | "decline" | "cancel" }`
|
|
952
1106
|
// (NOT `{ approved: boolean }`). agentex's UserInputResponse has no interrupt
|
|
953
1107
|
// concept, so allow → accept and deny → decline.
|
|
954
1108
|
if (!this.ctx.onUserInputRequest) {
|
|
955
1109
|
this.rpcResponse(id, { decision: "accept" });
|
|
956
|
-
|
|
1110
|
+
restore();
|
|
957
1111
|
return;
|
|
958
1112
|
}
|
|
959
1113
|
const toolName = method === "item/commandExecution/requestApproval"
|
|
@@ -963,15 +1117,66 @@ export class CodexSessionImpl {
|
|
|
963
1117
|
const resp = await this.ctx.onUserInputRequest({
|
|
964
1118
|
toolName,
|
|
965
1119
|
input: params,
|
|
966
|
-
|
|
967
|
-
|
|
1120
|
+
// `itemId`, not `id` — the protocol has no `id` here, so this was
|
|
1121
|
+
// always "". It is the field that lets a host line an approval up with
|
|
1122
|
+
// the tool_call event carrying the same item id.
|
|
1123
|
+
toolUseId: str(params, "itemId"),
|
|
1124
|
+
// FileChangeRequestApprovalParams carries `grantRoot`, never `path`,
|
|
1125
|
+
// so file-change approvals reached hosts with no description at all.
|
|
1126
|
+
description: str(params, "command")
|
|
1127
|
+
|| str(params, "grantRoot")
|
|
1128
|
+
|| str(params, "reason")
|
|
1129
|
+
|| undefined,
|
|
1130
|
+
// Which subagent is asking. The child thread id is also its
|
|
1131
|
+
// `background_task.taskId`, so a host can line the approval up with
|
|
1132
|
+
// the task it already renders. Absent for the root turn.
|
|
1133
|
+
...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
|
|
968
1134
|
});
|
|
969
1135
|
this.rpcResponse(id, { decision: resp.allow ? "accept" : "decline" });
|
|
970
1136
|
}
|
|
971
1137
|
catch {
|
|
972
1138
|
this.rpcResponse(id, { decision: "decline" });
|
|
973
1139
|
}
|
|
974
|
-
|
|
1140
|
+
restore();
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Handle `item/permissions/requestApproval`.
|
|
1144
|
+
*
|
|
1145
|
+
* Unlike the accept/decline approvals, the response here IS the grant: the
|
|
1146
|
+
* schema requires a `permissions` profile, so the generic "ack with `{}` to
|
|
1147
|
+
* unblock the turn" fallback this used to land in was schema-invalid.
|
|
1148
|
+
* Granting means echoing back the profile the agent asked for; refusing means
|
|
1149
|
+
* an empty profile, which is valid and grants nothing beyond what it already
|
|
1150
|
+
* has. With no host handler we refuse, because silently widening a sandbox is
|
|
1151
|
+
* not a safe default for an unattended session.
|
|
1152
|
+
*/
|
|
1153
|
+
async handlePermissionsApproval(id, params) {
|
|
1154
|
+
const requestThreadId = str(params, "threadId") || null;
|
|
1155
|
+
const foreign = this.isForeignThread(requestThreadId);
|
|
1156
|
+
if (!foreign)
|
|
1157
|
+
this._state = "waiting_for_approval";
|
|
1158
|
+
const restore = () => { if (!foreign)
|
|
1159
|
+
this.restoreStateAfter("waiting_for_approval"); };
|
|
1160
|
+
const requested = asObj(params, "permissions");
|
|
1161
|
+
if (!this.ctx.onUserInputRequest) {
|
|
1162
|
+
this.rpcResponse(id, { permissions: {} });
|
|
1163
|
+
restore();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
try {
|
|
1167
|
+
const resp = await this.ctx.onUserInputRequest({
|
|
1168
|
+
toolName: "permissions",
|
|
1169
|
+
input: params,
|
|
1170
|
+
toolUseId: str(params, "itemId"),
|
|
1171
|
+
description: str(params, "reason") || undefined,
|
|
1172
|
+
...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
|
|
1173
|
+
});
|
|
1174
|
+
this.rpcResponse(id, { permissions: resp.allow ? requested : {} });
|
|
1175
|
+
}
|
|
1176
|
+
catch {
|
|
1177
|
+
this.rpcResponse(id, { permissions: {} });
|
|
1178
|
+
}
|
|
1179
|
+
restore();
|
|
975
1180
|
}
|
|
976
1181
|
/**
|
|
977
1182
|
* Handle a Codex `requestUserInput` server→client request: the agent is asking
|
|
@@ -980,29 +1185,41 @@ export class CodexSessionImpl {
|
|
|
980
1185
|
* back in Codex's `{ answers: { [questionId]: { answers: string[] } } }` shape.
|
|
981
1186
|
*/
|
|
982
1187
|
async handleUserInputRequest(id, params) {
|
|
1188
|
+
// Same scoping as the approval handlers: a child subagent's question still
|
|
1189
|
+
// has to be answered, but it is not the root turn's business. Blocking root
|
|
1190
|
+
// state on it shows the session as waiting for input when the root turn is
|
|
1191
|
+
// running fine. `ToolRequestUserInputParams` requires `threadId`, so the
|
|
1192
|
+
// attribution is read from the wire rather than inferred.
|
|
1193
|
+
const requestThreadId = str(params, "threadId") || null;
|
|
1194
|
+
const foreign = this.isForeignThread(requestThreadId);
|
|
983
1195
|
// Questions are user *input*, not a tool-permission gate — distinct state so a
|
|
984
1196
|
// host UI can render a question form vs an approval prompt.
|
|
985
|
-
|
|
1197
|
+
if (!foreign)
|
|
1198
|
+
this._state = "waiting_for_input";
|
|
1199
|
+
const restore = () => { if (!foreign)
|
|
1200
|
+
this.restoreStateAfter("waiting_for_input"); };
|
|
986
1201
|
const questions = parseCodexQuestions(params);
|
|
987
1202
|
// No host handler, or nothing answerable → return empty answers so the
|
|
988
1203
|
// agent proceeds without hanging.
|
|
989
1204
|
if (!this.ctx.onUserInputRequest || questions.length === 0) {
|
|
990
1205
|
this.rpcResponse(id, { answers: {} });
|
|
991
|
-
|
|
1206
|
+
restore();
|
|
992
1207
|
return;
|
|
993
1208
|
}
|
|
994
1209
|
try {
|
|
995
1210
|
const resp = await this.ctx.onUserInputRequest({
|
|
996
1211
|
toolName: "AskUserQuestion",
|
|
997
1212
|
input: { questions },
|
|
998
|
-
toolUseId: str(params, "
|
|
1213
|
+
toolUseId: str(params, "itemId") || "codex-user-input",
|
|
1214
|
+
// Which subagent is asking, matching its `background_task.taskId`.
|
|
1215
|
+
...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
|
|
999
1216
|
});
|
|
1000
1217
|
this.rpcResponse(id, { answers: buildCodexUserInputAnswers(questions, resp) });
|
|
1001
1218
|
}
|
|
1002
1219
|
catch {
|
|
1003
1220
|
this.rpcResponse(id, { answers: {} });
|
|
1004
1221
|
}
|
|
1005
|
-
|
|
1222
|
+
restore();
|
|
1006
1223
|
}
|
|
1007
1224
|
// -------------------------------------------------------------------------
|
|
1008
1225
|
// Notification handling (v2 format)
|
|
@@ -1046,6 +1263,253 @@ export class CodexSessionImpl {
|
|
|
1046
1263
|
}
|
|
1047
1264
|
return null;
|
|
1048
1265
|
}
|
|
1266
|
+
backgroundTaskEvent(taskId, phase, status, options) {
|
|
1267
|
+
const rootThreadId = this._threadId ?? this._expectedThreadId;
|
|
1268
|
+
return {
|
|
1269
|
+
type: "background_task",
|
|
1270
|
+
taskId,
|
|
1271
|
+
taskType: "subagent",
|
|
1272
|
+
phase,
|
|
1273
|
+
status,
|
|
1274
|
+
description: options.description ?? null,
|
|
1275
|
+
summary: options.summary ?? null,
|
|
1276
|
+
parentTaskId: options.parentTaskId ?? null,
|
|
1277
|
+
timestamp: new Date().toISOString(),
|
|
1278
|
+
providerType: "codex",
|
|
1279
|
+
sessionId: rootThreadId,
|
|
1280
|
+
messageId: null,
|
|
1281
|
+
eventId: options.eventId ?? null,
|
|
1282
|
+
turnId: options.turnId ?? null,
|
|
1283
|
+
parentToolCallId: null,
|
|
1284
|
+
raw: options.raw,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
reconciledBackgroundTaskRaw(threadId, turn) {
|
|
1288
|
+
const error = turn ? str(asObj(turn, "error"), "message") || null : null;
|
|
1289
|
+
return {
|
|
1290
|
+
method: "thread/read",
|
|
1291
|
+
reconciled: true,
|
|
1292
|
+
threadId,
|
|
1293
|
+
turnId: turn ? str(turn, "id") || null : null,
|
|
1294
|
+
status: turn ? str(turn, "status") || null : null,
|
|
1295
|
+
error,
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Codex 0.144+ represents collaboration as a root collabAgentToolCall item.
|
|
1300
|
+
* A completed spawn call only means the child was created. Register every
|
|
1301
|
+
* receiver and then follow the child thread itself for the real terminus.
|
|
1302
|
+
*/
|
|
1303
|
+
/** @returns whether the item produced at least one background-task edge. */
|
|
1304
|
+
handleCollabAgentToolCall(item, raw, parentTaskId) {
|
|
1305
|
+
if (this._state === "closed" || !this.ctx.onEvent)
|
|
1306
|
+
return false;
|
|
1307
|
+
let dispatched = false;
|
|
1308
|
+
const tool = str(item, "tool");
|
|
1309
|
+
const states = asObj(item, "agentsStates");
|
|
1310
|
+
const receiverIds = stringArray(item["receiverThreadIds"]);
|
|
1311
|
+
const taskIds = [...new Set([...receiverIds, ...Object.keys(states)])];
|
|
1312
|
+
const canStart = tool === "spawnAgent";
|
|
1313
|
+
const metadataOnly = tool === "resumeAgent" || tool === "sendInput";
|
|
1314
|
+
const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
|
|
1315
|
+
for (const taskId of taskIds) {
|
|
1316
|
+
const previous = this._backgroundTasks.get(taskId);
|
|
1317
|
+
const description = str(item, "prompt") || previous?.description || null;
|
|
1318
|
+
const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
|
|
1319
|
+
// The child thread's own turn/started notification is the only
|
|
1320
|
+
// authoritative reactivation edge. Root resume/send calls can arrive
|
|
1321
|
+
// before or after that child turn, so they only enrich an active task.
|
|
1322
|
+
if (metadataOnly) {
|
|
1323
|
+
if (!previous || previous.terminal)
|
|
1324
|
+
continue;
|
|
1325
|
+
if (description && description !== previous.description) {
|
|
1326
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
|
|
1327
|
+
description,
|
|
1328
|
+
summary: previous.summary,
|
|
1329
|
+
parentTaskId: resolvedParentTaskId,
|
|
1330
|
+
eventId: this._threadId
|
|
1331
|
+
? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
|
|
1332
|
+
: null,
|
|
1333
|
+
raw,
|
|
1334
|
+
}));
|
|
1335
|
+
dispatched = true;
|
|
1336
|
+
}
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (!previous && !canStart)
|
|
1340
|
+
continue;
|
|
1341
|
+
if (!canStart && !canStopWithoutState && !(taskId in states))
|
|
1342
|
+
continue;
|
|
1343
|
+
const hasState = taskId in states;
|
|
1344
|
+
const state = !hasState && canStopWithoutState
|
|
1345
|
+
? { terminal: true, status: "stopped", summary: null }
|
|
1346
|
+
: codexAgentState(states[taskId]);
|
|
1347
|
+
if (!previous) {
|
|
1348
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "started", "running", {
|
|
1349
|
+
description,
|
|
1350
|
+
summary: null,
|
|
1351
|
+
parentTaskId: resolvedParentTaskId,
|
|
1352
|
+
eventId: this._threadId
|
|
1353
|
+
? `codex:${this._threadId}:background-task:${taskId}:started`
|
|
1354
|
+
: null,
|
|
1355
|
+
raw,
|
|
1356
|
+
}));
|
|
1357
|
+
dispatched = true;
|
|
1358
|
+
}
|
|
1359
|
+
else if (previous.terminal) {
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
else if (description && description !== previous.description) {
|
|
1363
|
+
// thread/started can beat the richer root collab item. Preserve one
|
|
1364
|
+
// start edge, then publish the prompt as ordinary progress metadata.
|
|
1365
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
|
|
1366
|
+
description,
|
|
1367
|
+
summary: previous.summary,
|
|
1368
|
+
parentTaskId: resolvedParentTaskId,
|
|
1369
|
+
eventId: this._threadId
|
|
1370
|
+
? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
|
|
1371
|
+
: null,
|
|
1372
|
+
raw,
|
|
1373
|
+
}));
|
|
1374
|
+
dispatched = true;
|
|
1375
|
+
}
|
|
1376
|
+
if (state.terminal) {
|
|
1377
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", state.status, {
|
|
1378
|
+
description,
|
|
1379
|
+
summary: state.summary,
|
|
1380
|
+
parentTaskId: resolvedParentTaskId,
|
|
1381
|
+
eventId: this._threadId
|
|
1382
|
+
? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "state"}:completed`
|
|
1383
|
+
: null,
|
|
1384
|
+
raw,
|
|
1385
|
+
}));
|
|
1386
|
+
dispatched = true;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return dispatched;
|
|
1390
|
+
}
|
|
1391
|
+
waitForBackgroundTaskPoll(pollKey) {
|
|
1392
|
+
if (this._state === "closed")
|
|
1393
|
+
return Promise.resolve();
|
|
1394
|
+
return new Promise((resolve) => {
|
|
1395
|
+
const timer = setTimeout(() => {
|
|
1396
|
+
this._backgroundTaskPollTimers.delete(pollKey);
|
|
1397
|
+
resolve();
|
|
1398
|
+
}, BACKGROUND_TASK_POLL_INTERVAL_MS);
|
|
1399
|
+
if (typeof timer.unref === "function")
|
|
1400
|
+
timer.unref();
|
|
1401
|
+
this._backgroundTaskPollTimers.set(pollKey, { timer, resolve });
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
startBackgroundTaskPoller(taskId) {
|
|
1405
|
+
if (this._state === "closed")
|
|
1406
|
+
return;
|
|
1407
|
+
const task = this._backgroundTasks.get(taskId);
|
|
1408
|
+
if (!task || task.terminal)
|
|
1409
|
+
return;
|
|
1410
|
+
const existing = this._backgroundTaskPollers.get(taskId);
|
|
1411
|
+
if (existing?.generation === task.generation)
|
|
1412
|
+
return;
|
|
1413
|
+
const generation = task.generation;
|
|
1414
|
+
const pollKey = `${taskId}:${generation}`;
|
|
1415
|
+
let poller;
|
|
1416
|
+
poller = this.pollBackgroundTask(taskId, generation, pollKey).finally(() => {
|
|
1417
|
+
if (this._backgroundTaskPollers.get(taskId)?.promise === poller) {
|
|
1418
|
+
this._backgroundTaskPollers.delete(taskId);
|
|
1419
|
+
}
|
|
1420
|
+
const pending = this._backgroundTaskPollTimers.get(pollKey);
|
|
1421
|
+
if (pending)
|
|
1422
|
+
clearTimeout(pending.timer);
|
|
1423
|
+
this._backgroundTaskPollTimers.delete(pollKey);
|
|
1424
|
+
});
|
|
1425
|
+
this._backgroundTaskPollers.set(taskId, { generation, promise: poller });
|
|
1426
|
+
}
|
|
1427
|
+
async pollBackgroundTask(taskId, generation, pollKey) {
|
|
1428
|
+
while (this._state !== "closed") {
|
|
1429
|
+
const task = this._backgroundTasks.get(taskId);
|
|
1430
|
+
if (!task || task.generation !== generation || task.terminal)
|
|
1431
|
+
return;
|
|
1432
|
+
try {
|
|
1433
|
+
const response = await this.boundedRpcRequest("thread/read", {
|
|
1434
|
+
threadId: taskId,
|
|
1435
|
+
includeTurns: true,
|
|
1436
|
+
});
|
|
1437
|
+
if (this.observeBackgroundTaskThread(response, taskId, generation))
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
// A child can briefly be pendingInit before thread/read can load it.
|
|
1442
|
+
}
|
|
1443
|
+
const current = this._backgroundTasks.get(taskId);
|
|
1444
|
+
if (!current || current.generation !== generation || current.terminal)
|
|
1445
|
+
return;
|
|
1446
|
+
await this.waitForBackgroundTaskPoll(pollKey);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
/** Return true once thread/read proves that the child's latest turn ended. */
|
|
1450
|
+
observeBackgroundTaskThread(response, taskId, generation) {
|
|
1451
|
+
const task = this._backgroundTasks.get(taskId);
|
|
1452
|
+
if (!task || task.generation !== generation || task.terminal)
|
|
1453
|
+
return true;
|
|
1454
|
+
const thread = asObj(response, "thread");
|
|
1455
|
+
const turns = Array.isArray(thread["turns"])
|
|
1456
|
+
? thread["turns"].map(obj)
|
|
1457
|
+
: [];
|
|
1458
|
+
const latestTurn = turns[turns.length - 1];
|
|
1459
|
+
if (!latestTurn) {
|
|
1460
|
+
const threadStatus = str(asObj(thread, "status"), "type");
|
|
1461
|
+
if (threadStatus !== "systemError")
|
|
1462
|
+
return false;
|
|
1463
|
+
const failureTurn = { status: "systemError", error: { message: "Subagent failed to initialize" } };
|
|
1464
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", "failed", {
|
|
1465
|
+
description: task.description,
|
|
1466
|
+
summary: "Subagent failed to initialize",
|
|
1467
|
+
parentTaskId: task.parentTaskId,
|
|
1468
|
+
eventId: this._threadId
|
|
1469
|
+
? `codex:${this._threadId}:background-task:${taskId}:system-error:completed`
|
|
1470
|
+
: null,
|
|
1471
|
+
raw: this.reconciledBackgroundTaskRaw(taskId, failureTurn),
|
|
1472
|
+
}));
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
const nativeStatus = str(latestTurn, "status");
|
|
1476
|
+
if (nativeStatus === "inProgress" || !nativeStatus)
|
|
1477
|
+
return false;
|
|
1478
|
+
const turnId = str(latestTurn, "id") || null;
|
|
1479
|
+
// The child turn/started notification is authoritative for a reactivated
|
|
1480
|
+
// generation. thread/read can briefly lag and still end at an older turn.
|
|
1481
|
+
if (task.activeTurnId && turnId !== task.activeTurnId)
|
|
1482
|
+
return false;
|
|
1483
|
+
const items = Array.isArray(latestTurn["items"])
|
|
1484
|
+
? latestTurn["items"].map(obj)
|
|
1485
|
+
: [];
|
|
1486
|
+
let summary = null;
|
|
1487
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
1488
|
+
const item = items[index];
|
|
1489
|
+
if (str(item, "type") !== "agentMessage")
|
|
1490
|
+
continue;
|
|
1491
|
+
summary = this.agentMessageText(item);
|
|
1492
|
+
if (summary)
|
|
1493
|
+
break;
|
|
1494
|
+
}
|
|
1495
|
+
const errorMessage = str(asObj(latestTurn, "error"), "message") || null;
|
|
1496
|
+
const status = nativeStatus === "failed"
|
|
1497
|
+
? "failed"
|
|
1498
|
+
: nativeStatus === "interrupted" || nativeStatus === "cancelled"
|
|
1499
|
+
? "stopped"
|
|
1500
|
+
: "completed";
|
|
1501
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", status, {
|
|
1502
|
+
description: task.description,
|
|
1503
|
+
summary: summary ?? errorMessage,
|
|
1504
|
+
parentTaskId: task.parentTaskId,
|
|
1505
|
+
turnId,
|
|
1506
|
+
eventId: this._threadId && turnId
|
|
1507
|
+
? `codex:${this._threadId}:background-task:${taskId}:${turnId}:completed`
|
|
1508
|
+
: null,
|
|
1509
|
+
raw: this.reconciledBackgroundTaskRaw(taskId, latestTurn),
|
|
1510
|
+
}));
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1049
1513
|
/**
|
|
1050
1514
|
* Maintain just enough child metadata to turn a later foreign-thread
|
|
1051
1515
|
* terminal notification into one provider-neutral task event. This reducer
|
|
@@ -1060,6 +1524,10 @@ export class CodexSessionImpl {
|
|
|
1060
1524
|
// explicitly reactivates the record below in handleForeignNotification.
|
|
1061
1525
|
if (previous?.terminal)
|
|
1062
1526
|
return false;
|
|
1527
|
+
// collabAgentToolCall and child thread/started can both announce the same
|
|
1528
|
+
// spawn. The first edge is sufficient and gives hosts one durable start.
|
|
1529
|
+
if (previous && event.phase === "started")
|
|
1530
|
+
return false;
|
|
1063
1531
|
const description = event.description ?? previous?.description ?? null;
|
|
1064
1532
|
const summary = event.summary ?? previous?.summary ?? null;
|
|
1065
1533
|
const parentTaskId = event.parentTaskId
|
|
@@ -1068,15 +1536,40 @@ export class CodexSessionImpl {
|
|
|
1068
1536
|
event.description = description;
|
|
1069
1537
|
event.summary = summary;
|
|
1070
1538
|
event.parentTaskId = parentTaskId;
|
|
1539
|
+
const terminal = event.phase === "completed";
|
|
1071
1540
|
this._backgroundTasks.set(event.taskId, {
|
|
1072
1541
|
taskId: event.taskId,
|
|
1542
|
+
// `description` survives termination: the reactivation path in
|
|
1543
|
+
// handleForeignNotification reads it precisely BECAUSE the record is
|
|
1544
|
+
// terminal, and dropping it lost a reactivated child's label forever.
|
|
1545
|
+
// It is one bounded string per child. `summary` is the unbounded one —
|
|
1546
|
+
// full agent messages, appended per item — and has no reader that
|
|
1547
|
+
// outlives the task, so it is released.
|
|
1073
1548
|
description,
|
|
1074
|
-
summary,
|
|
1549
|
+
summary: terminal ? null : summary,
|
|
1075
1550
|
parentTaskId,
|
|
1076
|
-
terminal
|
|
1551
|
+
terminal,
|
|
1552
|
+
generation: previous?.generation ?? 0,
|
|
1553
|
+
activeTurnId: terminal ? null : event.turnId ?? previous?.activeTurnId ?? null,
|
|
1077
1554
|
});
|
|
1555
|
+
// Kept past termination too: a grandchild can spawn after its parent has
|
|
1556
|
+
// finished, and lineage resolves through this index.
|
|
1078
1557
|
if (description)
|
|
1079
1558
|
this._backgroundTaskIdsByPath.set(description, event.taskId);
|
|
1559
|
+
// Every registration route converges here, so this is the one place that
|
|
1560
|
+
// can guarantee a reconciliation poller exists. Registering by way of the
|
|
1561
|
+
// parser's subAgentActivity branch previously produced a task with no
|
|
1562
|
+
// safety net at all, wholly dependent on the child notification arriving.
|
|
1563
|
+
//
|
|
1564
|
+
// Correctness rests on `startBackgroundTaskPoller` and the poll loop both
|
|
1565
|
+
// re-reading the record and bailing when it is terminal — NOT on the
|
|
1566
|
+
// microtask. The deferral is an optimization: one wire item can emit
|
|
1567
|
+
// several edges in a tick (a collab spawn whose child is already finished
|
|
1568
|
+
// dispatches `started` then `completed`), and yielding first means the
|
|
1569
|
+
// common case declines before spawning a `thread/read` rather than after.
|
|
1570
|
+
// Do not remove the terminal checks on the grounds that this defers.
|
|
1571
|
+
if (!terminal)
|
|
1572
|
+
queueMicrotask(() => this.startBackgroundTaskPoller(event.taskId));
|
|
1080
1573
|
return true;
|
|
1081
1574
|
}
|
|
1082
1575
|
/**
|
|
@@ -1093,8 +1586,21 @@ export class CodexSessionImpl {
|
|
|
1093
1586
|
// App-server can publish child thread/started before the corresponding
|
|
1094
1587
|
// root subAgentActivity item. Register only descendants of this session,
|
|
1095
1588
|
// not unrelated foreign threads multiplexed by a future server version.
|
|
1096
|
-
if (parentThreadId !== rootThreadId && !parentTask)
|
|
1589
|
+
if (parentThreadId !== rootThreadId && !parentTask) {
|
|
1590
|
+
// Say so. A wire shape that omits parentThreadId takes this branch for
|
|
1591
|
+
// every child, which silently disables the whole registration and
|
|
1592
|
+
// reconciliation path — the safety net that exists precisely because
|
|
1593
|
+
// child notifications cannot be relied on. Degrading quietly here
|
|
1594
|
+
// hides the one failure this machinery was built to survive.
|
|
1595
|
+
if (!parentThreadId && this.ctx.onOutput) {
|
|
1596
|
+
try {
|
|
1597
|
+
void this.ctx.onOutput("stderr", `agentex: codex child thread ${childThreadId} announced no parent thread id; `
|
|
1598
|
+
+ "background-task tracking and reconciliation are disabled for it\n");
|
|
1599
|
+
}
|
|
1600
|
+
catch { /* swallow */ }
|
|
1601
|
+
}
|
|
1097
1602
|
return;
|
|
1603
|
+
}
|
|
1098
1604
|
const source = asObj(thread, "source");
|
|
1099
1605
|
const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
|
|
1100
1606
|
? asObj(source, "subAgent")
|
|
@@ -1138,6 +1644,7 @@ export class CodexSessionImpl {
|
|
|
1138
1644
|
return;
|
|
1139
1645
|
task.terminal = false;
|
|
1140
1646
|
task.summary = null;
|
|
1647
|
+
task.generation += 1;
|
|
1141
1648
|
const turn = asObj(params, "turn");
|
|
1142
1649
|
const turnId = str(turn, "id") || str(params, "turnId") || null;
|
|
1143
1650
|
const rootThreadId = this._threadId ?? this._expectedThreadId;
|
|
@@ -1166,6 +1673,10 @@ export class CodexSessionImpl {
|
|
|
1166
1673
|
if (method === "item/completed") {
|
|
1167
1674
|
const item = asObj(params, "item");
|
|
1168
1675
|
const itemType = str(item, "type");
|
|
1676
|
+
if (itemType === "collabAgentToolCall") {
|
|
1677
|
+
this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, childThreadId);
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1169
1680
|
if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
|
|
1170
1681
|
const summary = this.agentMessageText(item);
|
|
1171
1682
|
if (summary)
|
|
@@ -1205,10 +1716,19 @@ export class CodexSessionImpl {
|
|
|
1205
1716
|
: null,
|
|
1206
1717
|
turnId,
|
|
1207
1718
|
parentToolCallId: null,
|
|
1208
|
-
raw:
|
|
1719
|
+
raw: {
|
|
1720
|
+
method,
|
|
1721
|
+
reconciled: false,
|
|
1722
|
+
threadId: childThreadId,
|
|
1723
|
+
turnId,
|
|
1724
|
+
status: nativeStatus || null,
|
|
1725
|
+
error: errorMessage || null,
|
|
1726
|
+
},
|
|
1209
1727
|
});
|
|
1210
1728
|
}
|
|
1211
1729
|
handleNotification(method, params, rawLine) {
|
|
1730
|
+
if (this._state === "closed")
|
|
1731
|
+
return;
|
|
1212
1732
|
// codex/event — legacy wrapper
|
|
1213
1733
|
if (method === "codex/event") {
|
|
1214
1734
|
const innerMsg = str(params, "msg");
|
|
@@ -1251,6 +1771,17 @@ export class CodexSessionImpl {
|
|
|
1251
1771
|
if (method === "item/completed") {
|
|
1252
1772
|
this._state = "thinking";
|
|
1253
1773
|
this.extractSummaryFromItem(params);
|
|
1774
|
+
const item = asObj(params, "item");
|
|
1775
|
+
if (str(item, "type") === "collabAgentToolCall") {
|
|
1776
|
+
// Shapes we model become background_task edges. Anything that produced
|
|
1777
|
+
// no edge still reaches the host as a raw `unknown` event rather than
|
|
1778
|
+
// vanishing — that escape hatch is how unmodeled wire shapes stay
|
|
1779
|
+
// visible, and collab is exactly where new shapes keep appearing.
|
|
1780
|
+
if (!this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null)) {
|
|
1781
|
+
this.emitStreamEvent(rawLine);
|
|
1782
|
+
}
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1254
1785
|
this.emitStreamEvent(rawLine);
|
|
1255
1786
|
return;
|
|
1256
1787
|
}
|
|
@@ -1286,6 +1817,8 @@ export class CodexSessionImpl {
|
|
|
1286
1817
|
// Legacy event handling (NDJSON events with `type` field)
|
|
1287
1818
|
// -------------------------------------------------------------------------
|
|
1288
1819
|
handleLegacyEvent(event, rawLine) {
|
|
1820
|
+
if (this._state === "closed")
|
|
1821
|
+
return;
|
|
1289
1822
|
const type = str(event, "type");
|
|
1290
1823
|
const eventThreadId = str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
|
|
1291
1824
|
// Older NDJSON-shaped events can also carry explicit thread scope. Keep
|
|
@@ -1418,10 +1951,35 @@ export class CodexSessionImpl {
|
|
|
1418
1951
|
this.resolveTurn();
|
|
1419
1952
|
}
|
|
1420
1953
|
resolveTurn() {
|
|
1954
|
+
// Bind delivery to the turn that just terminated. Everything below this
|
|
1955
|
+
// point can yield (usage scan, event chain), and a send() arriving in the
|
|
1956
|
+
// meantime starts a new turn that must not be settled by this result.
|
|
1957
|
+
const delivering = this._activeTurnReady;
|
|
1421
1958
|
const resolvedModel = this._turnModel ?? this.model;
|
|
1422
1959
|
let usage = this._turnUsage && resolvedModel
|
|
1423
1960
|
? { [resolvedModel]: { inputTokens: this._turnUsage.inputTokens, outputTokens: this._turnUsage.outputTokens } }
|
|
1424
1961
|
: undefined;
|
|
1962
|
+
// Freeze this turn's outcome and clear the accumulators NOW, while we are
|
|
1963
|
+
// still synchronous with the terminal frame. Delivery reads the snapshot,
|
|
1964
|
+
// so a turn that starts during the async gap below neither inherits these
|
|
1965
|
+
// values nor has its own overwritten. Resetting inside beginActiveTurn()
|
|
1966
|
+
// instead would not work: the usage-scan path re-reads state after an
|
|
1967
|
+
// await, so the new turn's reset would eat the old turn's summary.
|
|
1968
|
+
const outcome = {
|
|
1969
|
+
summary: this._turnSummary,
|
|
1970
|
+
isError: this._turnIsError,
|
|
1971
|
+
errorMessage: this._turnErrorMessage,
|
|
1972
|
+
wasInterrupted: this._turnWasInterrupted,
|
|
1973
|
+
};
|
|
1974
|
+
this._turnSummary = null;
|
|
1975
|
+
this._turnUsage = null;
|
|
1976
|
+
this._turnModel = null;
|
|
1977
|
+
this._turnIsError = false;
|
|
1978
|
+
this._turnWasInterrupted = false;
|
|
1979
|
+
this._turnErrorMessage = null;
|
|
1980
|
+
// Captured before clearing: the usage-scan fallback below still needs it.
|
|
1981
|
+
const turnStartedAt = this._turnStartedAt;
|
|
1982
|
+
this._turnStartedAt = null;
|
|
1425
1983
|
// Usage precedence: the `turn.completed` payload is authoritative when
|
|
1426
1984
|
// present (captured above into _turnUsage). Only when the stream carried no
|
|
1427
1985
|
// usage do we fall back to scanning Codex's on-disk session logs.
|
|
@@ -1431,8 +1989,8 @@ export class CodexSessionImpl {
|
|
|
1431
1989
|
// latest turn or read partially-written totals. We therefore scan ONLY when
|
|
1432
1990
|
// there is no in-band usage, and never let a scan failure fail the turn
|
|
1433
1991
|
// (usage simply stays undefined). Prefer the in-band payload always.
|
|
1434
|
-
if (!usage &&
|
|
1435
|
-
const startedAt =
|
|
1992
|
+
if (!usage && turnStartedAt) {
|
|
1993
|
+
const startedAt = turnStartedAt;
|
|
1436
1994
|
const threadId = this._threadId ?? undefined;
|
|
1437
1995
|
// Fire-and-forget: scan logs then deliver result
|
|
1438
1996
|
void scanCodexSessionUsage({ startedAfter: startedAt, threadId }).then((scanned) => {
|
|
@@ -1440,20 +1998,20 @@ export class CodexSessionImpl {
|
|
|
1440
1998
|
}).catch(() => {
|
|
1441
1999
|
// Non-fatal — usage stays undefined
|
|
1442
2000
|
}).finally(() => {
|
|
1443
|
-
void this.deliverTurnResult(usage);
|
|
2001
|
+
void this.deliverTurnResult(usage, delivering, outcome);
|
|
1444
2002
|
});
|
|
1445
2003
|
return;
|
|
1446
2004
|
}
|
|
1447
|
-
void this.deliverTurnResult(usage);
|
|
2005
|
+
void this.deliverTurnResult(usage, delivering, outcome);
|
|
1448
2006
|
}
|
|
1449
|
-
async deliverTurnResult(usage) {
|
|
2007
|
+
async deliverTurnResult(usage, delivering, outcome) {
|
|
1450
2008
|
const result = {
|
|
1451
|
-
summary:
|
|
2009
|
+
summary: outcome.summary,
|
|
1452
2010
|
usage,
|
|
1453
2011
|
costUsd: null,
|
|
1454
|
-
status:
|
|
1455
|
-
errorCode:
|
|
1456
|
-
errorMessage:
|
|
2012
|
+
status: outcome.wasInterrupted ? "aborted" : outcome.isError ? "failed" : "completed",
|
|
2013
|
+
errorCode: outcome.wasInterrupted ? "aborted" : outcome.isError ? "execution_error" : null,
|
|
2014
|
+
errorMessage: outcome.wasInterrupted ? "Turn was interrupted" : outcome.errorMessage,
|
|
1457
2015
|
};
|
|
1458
2016
|
// Drain pending onEvent handlers so callers awaiting send() see a settled
|
|
1459
2017
|
// DB / log / UI state by the time TurnResult resolves. The chain snapshot
|
|
@@ -1467,20 +2025,27 @@ export class CodexSessionImpl {
|
|
|
1467
2025
|
// session whose stdin is dead.
|
|
1468
2026
|
if (this._state === "closed")
|
|
1469
2027
|
return;
|
|
1470
|
-
this
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
this.
|
|
1482
|
-
this.
|
|
1483
|
-
|
|
2028
|
+
// A send() that landed while this delivery was suspended has already begun
|
|
2029
|
+
// a new turn and owns the session. Its result is still owed to it, so this
|
|
2030
|
+
// delivery settles only its OWN generation and touches neither the state
|
|
2031
|
+
// nor the interrupt latch the new turn needs to stay stoppable.
|
|
2032
|
+
//
|
|
2033
|
+
// `delivering === null` must take this branch too. A duplicate terminal
|
|
2034
|
+
// frame for an already-delivered turn (codex can report failure through
|
|
2035
|
+
// both `turn/completed` with a failed status and a separate `turn/failed`)
|
|
2036
|
+
// reaches here with a null latch, and exempting it restored every symptom
|
|
2037
|
+
// the fence exists to prevent: silent Stop, idle-while-running, and a
|
|
2038
|
+
// stale settle.
|
|
2039
|
+
const superseded = this._activeTurnReady !== delivering;
|
|
2040
|
+
// Drain the pending send() resolvers belonging to this turn. Several
|
|
2041
|
+
// concurrent sends coalesced into one turn share the same TurnResult; a
|
|
2042
|
+
// send from a later turn is left in place for its own delivery.
|
|
2043
|
+
const pending = this.takePendingResults(delivering?.generation);
|
|
2044
|
+
if (!superseded) {
|
|
2045
|
+
this._state = "idle";
|
|
2046
|
+
// Accumulators were already snapshotted and cleared in resolveTurn().
|
|
2047
|
+
this.clearActiveTurn(undefined, delivering ?? undefined);
|
|
2048
|
+
}
|
|
1484
2049
|
for (const p of pending) {
|
|
1485
2050
|
// Skip sends already settled early by timeout / abort.
|
|
1486
2051
|
if (p.settled)
|
|
@@ -1500,9 +2065,9 @@ export class CodexSessionImpl {
|
|
|
1500
2065
|
// Pass current threadId so NDJSON-shaped events (via codex/event wrapper)
|
|
1501
2066
|
// carry sessionId. v2 notifications parse threadId from params directly
|
|
1502
2067
|
// and ignore this arg.
|
|
1503
|
-
const event
|
|
1504
|
-
if (event)
|
|
2068
|
+
for (const event of parseCodexStreamLines(rawLine, this._threadId)) {
|
|
1505
2069
|
this.dispatchEvent(event);
|
|
2070
|
+
}
|
|
1506
2071
|
}
|
|
1507
2072
|
/**
|
|
1508
2073
|
* Queue an event for in-order delivery to `onEvent`. Each call appends a
|