@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
|
@@ -27,7 +27,7 @@ import { translateEndpoint } from "../../utils/endpoint.js";
|
|
|
27
27
|
import { injectWorkspaceSkills } from "../../utils/skills.js";
|
|
28
28
|
import { resolveInstructions } from "../../utils/instructions.js";
|
|
29
29
|
import { createToolNameTracker } from "../../utils/tool-names.js";
|
|
30
|
-
import {
|
|
30
|
+
import { parseCodexStreamLines } from "./parse.js";
|
|
31
31
|
import { withPlanModePreamble } from "./plan-mode.js";
|
|
32
32
|
import { scanCodexSessionUsage } from "./usage-scanner.js";
|
|
33
33
|
import { codexSessionCodec } from "./codec.js";
|
|
@@ -130,6 +130,12 @@ interface PendingResult {
|
|
|
130
130
|
settled?: boolean;
|
|
131
131
|
/** Tear down this send's timeout timer / abort listener. */
|
|
132
132
|
cleanup?: () => void;
|
|
133
|
+
/**
|
|
134
|
+
* Turn generation this send joined. Result delivery drains only the
|
|
135
|
+
* generation it belongs to, so a send that starts a NEW turn while the
|
|
136
|
+
* previous turn's result is still draining cannot be settled by it.
|
|
137
|
+
*/
|
|
138
|
+
generation: number;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
interface TrackedBackgroundTask {
|
|
@@ -138,14 +144,42 @@ interface TrackedBackgroundTask {
|
|
|
138
144
|
summary: string | null;
|
|
139
145
|
parentTaskId: string | null;
|
|
140
146
|
terminal: boolean;
|
|
147
|
+
generation: number;
|
|
148
|
+
activeTurnId: string | null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface CodexBackgroundTaskState {
|
|
152
|
+
terminal: boolean;
|
|
153
|
+
status: "running" | "completed" | "failed" | "stopped";
|
|
154
|
+
summary: string | null;
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
const BACKGROUND_TASK_POLL_INTERVAL_MS = 2_000;
|
|
158
|
+
const BACKGROUND_TASK_READ_TIMEOUT_MS = 5_000;
|
|
159
|
+
|
|
143
160
|
/** Identity latch for the root turn currently represented by this session. */
|
|
161
|
+
/**
|
|
162
|
+
* One turn's outcome, frozen at the terminal frame.
|
|
163
|
+
*
|
|
164
|
+
* Delivery is asynchronous (event chain, and an on-disk usage scan when the
|
|
165
|
+
* stream carried no usage), so reading the live accumulators at delivery time
|
|
166
|
+
* reads whatever turn is running *then*. Snapshotting decouples the two: the
|
|
167
|
+
* accumulators reset immediately and belong to the next turn from that moment.
|
|
168
|
+
*/
|
|
169
|
+
interface TurnOutcome {
|
|
170
|
+
summary: string | null;
|
|
171
|
+
isError: boolean;
|
|
172
|
+
errorMessage: string | null;
|
|
173
|
+
wasInterrupted: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
144
176
|
interface ActiveTurnReady {
|
|
145
177
|
promise: Promise<string | null>;
|
|
146
178
|
resolve: (turnId: string | null) => void;
|
|
147
179
|
reject: (err: Error) => void;
|
|
148
180
|
settled: boolean;
|
|
181
|
+
/** Monotonic id for this turn, used to fence late result delivery. */
|
|
182
|
+
generation: number;
|
|
149
183
|
}
|
|
150
184
|
|
|
151
185
|
// ---------------------------------------------------------------------------
|
|
@@ -179,6 +213,32 @@ function asObj(parent: Record<string, unknown>, key: string): Record<string, unk
|
|
|
179
213
|
: {};
|
|
180
214
|
}
|
|
181
215
|
|
|
216
|
+
function obj(value: unknown): Record<string, unknown> {
|
|
217
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
218
|
+
? (value as Record<string, unknown>)
|
|
219
|
+
: {};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stringArray(value: unknown): string[] {
|
|
223
|
+
return Array.isArray(value)
|
|
224
|
+
? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
225
|
+
: [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function codexAgentState(value: unknown): CodexBackgroundTaskState {
|
|
229
|
+
const state = obj(value);
|
|
230
|
+
const nativeStatus = str(state, "status");
|
|
231
|
+
const summary = str(state, "message") || null;
|
|
232
|
+
if (nativeStatus === "completed") return { terminal: true, status: "completed", summary };
|
|
233
|
+
if (nativeStatus === "errored" || nativeStatus === "notFound") {
|
|
234
|
+
return { terminal: true, status: "failed", summary };
|
|
235
|
+
}
|
|
236
|
+
if (nativeStatus === "interrupted" || nativeStatus === "shutdown") {
|
|
237
|
+
return { terminal: true, status: "stopped", summary };
|
|
238
|
+
}
|
|
239
|
+
return { terminal: false, status: "running", summary };
|
|
240
|
+
}
|
|
241
|
+
|
|
182
242
|
/** Discriminated incoming message from the Codex CLI. */
|
|
183
243
|
type IncomingMessage =
|
|
184
244
|
| { kind: "response"; id: number; result?: Record<string, unknown>; error?: { code: number; message: string } }
|
|
@@ -366,6 +426,8 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
366
426
|
*/
|
|
367
427
|
private _activeTurnId: string | null = null;
|
|
368
428
|
private _activeTurnReady: ActiveTurnReady | null = null;
|
|
429
|
+
/** Incremented per turn latch. Never reused, so staleness is decidable. */
|
|
430
|
+
private _turnGeneration = 0;
|
|
369
431
|
/** Successful repeated interrupts coalesce until the terminal notification. */
|
|
370
432
|
private _interruptPromise: Promise<void> | null = null;
|
|
371
433
|
/** Prevents a late interrupt request after the terminal frame was observed. */
|
|
@@ -387,6 +449,16 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
387
449
|
/** Child-agent lifecycle is informational and never participates in root turn settlement. */
|
|
388
450
|
private readonly _backgroundTasks = new Map<string, TrackedBackgroundTask>();
|
|
389
451
|
private readonly _backgroundTaskIdsByPath = new Map<string, string>();
|
|
452
|
+
/** One protocol-native thread/read poller per child when Codex does not multiplex its notifications. */
|
|
453
|
+
private readonly _backgroundTaskPollers = new Map<string, {
|
|
454
|
+
generation: number;
|
|
455
|
+
promise: Promise<void>;
|
|
456
|
+
}>();
|
|
457
|
+
/** Cancellable retry delay for each poller. Cleared when the session closes. */
|
|
458
|
+
private readonly _backgroundTaskPollTimers = new Map<string, {
|
|
459
|
+
timer: ReturnType<typeof setTimeout>;
|
|
460
|
+
resolve: () => void;
|
|
461
|
+
}>();
|
|
390
462
|
|
|
391
463
|
/**
|
|
392
464
|
* Serial dispatch chain for `onEvent`. Each dispatched event appends a
|
|
@@ -456,7 +528,14 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
456
528
|
proc.on("exit", (code, signal) => {
|
|
457
529
|
if (this._state !== "closed") {
|
|
458
530
|
this._state = "closed";
|
|
459
|
-
const
|
|
531
|
+
const message = `Codex process exited unexpectedly (code=${code}, signal=${signal})`;
|
|
532
|
+
this.finalizeActiveBackgroundTasks("failed", message, {
|
|
533
|
+
method: "process/exit",
|
|
534
|
+
code,
|
|
535
|
+
signal,
|
|
536
|
+
});
|
|
537
|
+
this.cancelBackgroundTaskPollTimers();
|
|
538
|
+
const err = new Error(message);
|
|
460
539
|
this.rejectAllPending(err);
|
|
461
540
|
}
|
|
462
541
|
});
|
|
@@ -464,11 +543,59 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
464
543
|
proc.on("error", (err) => {
|
|
465
544
|
if (this._state !== "closed") {
|
|
466
545
|
this._state = "closed";
|
|
546
|
+
this.finalizeActiveBackgroundTasks("failed", err.message, {
|
|
547
|
+
method: "process/error",
|
|
548
|
+
message: err.message,
|
|
549
|
+
});
|
|
550
|
+
this.cancelBackgroundTaskPollTimers();
|
|
467
551
|
this.rejectAllPending(err);
|
|
468
552
|
}
|
|
469
553
|
});
|
|
470
554
|
}
|
|
471
555
|
|
|
556
|
+
private cancelBackgroundTaskPollTimers(): void {
|
|
557
|
+
for (const pending of this._backgroundTaskPollTimers.values()) {
|
|
558
|
+
clearTimeout(pending.timer);
|
|
559
|
+
pending.resolve();
|
|
560
|
+
}
|
|
561
|
+
this._backgroundTaskPollTimers.clear();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private finalizeActiveBackgroundTasks(
|
|
565
|
+
status: "failed" | "stopped",
|
|
566
|
+
summary: string,
|
|
567
|
+
raw: Record<string, unknown>,
|
|
568
|
+
): void {
|
|
569
|
+
for (const task of this._backgroundTasks.values()) {
|
|
570
|
+
if (task.terminal) continue;
|
|
571
|
+
this.dispatchEvent(this.backgroundTaskEvent(task.taskId, "completed", status, {
|
|
572
|
+
description: task.description,
|
|
573
|
+
summary,
|
|
574
|
+
parentTaskId: task.parentTaskId,
|
|
575
|
+
eventId: this._threadId
|
|
576
|
+
? `codex:${this._threadId}:background-task:${task.taskId}:session-${status}:completed`
|
|
577
|
+
: null,
|
|
578
|
+
raw,
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Remove and return the pending sends belonging to one turn generation.
|
|
585
|
+
*
|
|
586
|
+
* `undefined` matches entries with no generation, which is how tests that
|
|
587
|
+
* drive the session without `send()` enqueue resolvers.
|
|
588
|
+
*/
|
|
589
|
+
private takePendingResults(generation: number | undefined): PendingResult[] {
|
|
590
|
+
const mine: PendingResult[] = [];
|
|
591
|
+
const others: PendingResult[] = [];
|
|
592
|
+
for (const entry of this._pendingResults) {
|
|
593
|
+
(entry.generation === generation ? mine : others).push(entry);
|
|
594
|
+
}
|
|
595
|
+
this._pendingResults = others;
|
|
596
|
+
return mine;
|
|
597
|
+
}
|
|
598
|
+
|
|
472
599
|
/** Reject every pending send() Promise and outgoing JSON-RPC call. */
|
|
473
600
|
private rejectAllPending(err: Error): void {
|
|
474
601
|
const pending = this._pendingResults.splice(0);
|
|
@@ -503,6 +630,7 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
503
630
|
resolve: resolveFn,
|
|
504
631
|
reject: rejectFn,
|
|
505
632
|
settled: false,
|
|
633
|
+
generation: ++this._turnGeneration,
|
|
506
634
|
};
|
|
507
635
|
this._activeTurnId = null;
|
|
508
636
|
this._activeTurnReady = ready;
|
|
@@ -528,9 +656,16 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
528
656
|
expected.reject(err);
|
|
529
657
|
}
|
|
530
658
|
|
|
531
|
-
/**
|
|
532
|
-
|
|
659
|
+
/**
|
|
660
|
+
* Clear the current turn and release an interrupt waiting for its id.
|
|
661
|
+
*
|
|
662
|
+
* `expected` fences the clear against a turn that started after the one being
|
|
663
|
+
* torn down. Without it, a late teardown nulls the latch a newer, running
|
|
664
|
+
* turn is relying on, which is the same silent-Stop failure by another route.
|
|
665
|
+
*/
|
|
666
|
+
private clearActiveTurn(err?: Error, expected?: ActiveTurnReady): void {
|
|
533
667
|
const ready = this._activeTurnReady;
|
|
668
|
+
if (expected && ready !== expected) return;
|
|
534
669
|
if (ready && !ready.settled) {
|
|
535
670
|
ready.settled = true;
|
|
536
671
|
if (err) ready.reject(err);
|
|
@@ -584,6 +719,35 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
584
719
|
});
|
|
585
720
|
}
|
|
586
721
|
|
|
722
|
+
/** A bounded RPC whose pending-map entry is removed if Codex never replies. */
|
|
723
|
+
private boundedRpcRequest(
|
|
724
|
+
method: string,
|
|
725
|
+
params: Record<string, unknown>,
|
|
726
|
+
timeoutMs = BACKGROUND_TASK_READ_TIMEOUT_MS,
|
|
727
|
+
): Promise<Record<string, unknown>> {
|
|
728
|
+
const id = this._nextId++;
|
|
729
|
+
this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
730
|
+
|
|
731
|
+
return new Promise((resolve, reject) => {
|
|
732
|
+
const timer = setTimeout(() => {
|
|
733
|
+
if (!this._pendingRpc.delete(id)) return;
|
|
734
|
+
reject(new Error(`codex ${method} timed out`));
|
|
735
|
+
}, timeoutMs);
|
|
736
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
737
|
+
|
|
738
|
+
this._pendingRpc.set(id, {
|
|
739
|
+
resolve: (result) => {
|
|
740
|
+
clearTimeout(timer);
|
|
741
|
+
resolve(result);
|
|
742
|
+
},
|
|
743
|
+
reject: (err) => {
|
|
744
|
+
clearTimeout(timer);
|
|
745
|
+
reject(err);
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
587
751
|
/**
|
|
588
752
|
* Bounded RPC for experimental, best-effort methods (the `thread/goal/*`
|
|
589
753
|
* family). An app-server build that doesn't recognize the method may never
|
|
@@ -719,9 +883,17 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
719
883
|
// and pass through. If the second `turn/start` lands during the first
|
|
720
884
|
// turn, the per-turn accumulators continue collecting until the result
|
|
721
885
|
// event fires; the result then drains all pending resolvers.
|
|
886
|
+
// Joining the active turn requires that it still be active. The latch
|
|
887
|
+
// survives the terminal frame until deliverTurnResult() clears it, and that
|
|
888
|
+
// gap is real time: it spans `await this._eventChain` (a host persisting
|
|
889
|
+
// events) and, when turn/completed carried no usage, an on-disk usage scan.
|
|
890
|
+
// A send landing in that gap belongs to a NEW turn — joining the dead one
|
|
891
|
+
// would leave it with no interrupt latch and no turn/start capture, so Stop
|
|
892
|
+
// would find nothing to interrupt and return as if it had succeeded.
|
|
722
893
|
const existingTurnReady = this._activeTurnReady;
|
|
723
|
-
const
|
|
724
|
-
const
|
|
894
|
+
const canJoinActiveTurn = existingTurnReady !== null && !this._turnTerminalObserved;
|
|
895
|
+
const isTurnLeader = !canJoinActiveTurn;
|
|
896
|
+
const turnReady = canJoinActiveTurn ? existingTurnReady! : this.beginActiveTurn();
|
|
725
897
|
|
|
726
898
|
if (this._state === "idle") {
|
|
727
899
|
this._state = "thinking";
|
|
@@ -753,7 +925,11 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
753
925
|
rejectFn = reject;
|
|
754
926
|
});
|
|
755
927
|
|
|
756
|
-
const entry: PendingResult = {
|
|
928
|
+
const entry: PendingResult = {
|
|
929
|
+
resolve: resolveFn,
|
|
930
|
+
reject: rejectFn,
|
|
931
|
+
generation: turnReady.generation,
|
|
932
|
+
};
|
|
757
933
|
this._pendingResults.push(entry);
|
|
758
934
|
|
|
759
935
|
// Track the in-flight turn so drain() can await it; drop it on settle.
|
|
@@ -953,6 +1129,14 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
953
1129
|
async close(): Promise<void> {
|
|
954
1130
|
if (this._state === "closed") return;
|
|
955
1131
|
this._state = "closed";
|
|
1132
|
+
this.finalizeActiveBackgroundTasks("stopped", "Codex session closed", {
|
|
1133
|
+
method: "session/closed",
|
|
1134
|
+
});
|
|
1135
|
+
this.cancelBackgroundTaskPollTimers();
|
|
1136
|
+
// finalizeActiveBackgroundTasks above has already emitted the terminal
|
|
1137
|
+
// edges, so nothing downstream needs these records any more.
|
|
1138
|
+
this._backgroundTasks.clear();
|
|
1139
|
+
this._backgroundTaskIdsByPath.clear();
|
|
956
1140
|
this.rejectAllPending(new Error("Codex session closed"));
|
|
957
1141
|
|
|
958
1142
|
this.proc.stdin!.end();
|
|
@@ -1043,6 +1227,8 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1043
1227
|
method === "item/fileChange/requestApproval"
|
|
1044
1228
|
) {
|
|
1045
1229
|
void this.handleApproval(id, method, params);
|
|
1230
|
+
} else if (method === "item/permissions/requestApproval") {
|
|
1231
|
+
void this.handlePermissionsApproval(id, params);
|
|
1046
1232
|
} else if (
|
|
1047
1233
|
method === "item/tool/requestUserInput" ||
|
|
1048
1234
|
method === "tool/requestUserInput"
|
|
@@ -1050,7 +1236,13 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1050
1236
|
// `tool/requestUserInput` is the legacy method name on older codex builds.
|
|
1051
1237
|
void this.handleUserInputRequest(id, params);
|
|
1052
1238
|
} else {
|
|
1053
|
-
// Unknown server request
|
|
1239
|
+
// Unknown server request. `{}` is a best-effort reply, NOT a guarantee
|
|
1240
|
+
// that the turn proceeds: every server request the protocol defines but
|
|
1241
|
+
// this file does not handle declares required response fields, so an
|
|
1242
|
+
// empty result is schema-invalid for all of them. A JSON-RPC error reply
|
|
1243
|
+
// would be the honest answer, but `rpcResponse` has no error path and
|
|
1244
|
+
// introducing one changes behavior (an error can abort a turn where this
|
|
1245
|
+
// limps along), so that is a deliberate follow-up rather than a fix here.
|
|
1054
1246
|
this.rpcResponse(id, {});
|
|
1055
1247
|
}
|
|
1056
1248
|
}
|
|
@@ -1067,14 +1259,23 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1067
1259
|
}
|
|
1068
1260
|
|
|
1069
1261
|
private async handleApproval(id: number, method: string, params: Record<string, unknown>): Promise<void> {
|
|
1070
|
-
|
|
1262
|
+
// A child thread's approval still has to be answered — somebody must decide
|
|
1263
|
+
// whether that subagent may run its command — but it is not the root turn's
|
|
1264
|
+
// business. Driving root state from it would show the session as blocked on
|
|
1265
|
+
// approval when the root turn is running fine, and `restoreStateAfter` would
|
|
1266
|
+
// then recompute root state from a child's lifecycle. The protocol makes
|
|
1267
|
+
// `threadId` mandatory on every approval, so the attribution is free.
|
|
1268
|
+
const requestThreadId = str(params, "threadId") || null;
|
|
1269
|
+
const foreign = this.isForeignThread(requestThreadId);
|
|
1270
|
+
if (!foreign) this._state = "waiting_for_approval";
|
|
1271
|
+
const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_approval"); };
|
|
1071
1272
|
|
|
1072
1273
|
// Codex's app-server expects `{ decision: "accept" | "decline" | "cancel" }`
|
|
1073
1274
|
// (NOT `{ approved: boolean }`). agentex's UserInputResponse has no interrupt
|
|
1074
1275
|
// concept, so allow → accept and deny → decline.
|
|
1075
1276
|
if (!this.ctx.onUserInputRequest) {
|
|
1076
1277
|
this.rpcResponse(id, { decision: "accept" });
|
|
1077
|
-
|
|
1278
|
+
restore();
|
|
1078
1279
|
return;
|
|
1079
1280
|
}
|
|
1080
1281
|
|
|
@@ -1086,15 +1287,67 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1086
1287
|
const resp = await this.ctx.onUserInputRequest({
|
|
1087
1288
|
toolName,
|
|
1088
1289
|
input: params,
|
|
1089
|
-
|
|
1090
|
-
|
|
1290
|
+
// `itemId`, not `id` — the protocol has no `id` here, so this was
|
|
1291
|
+
// always "". It is the field that lets a host line an approval up with
|
|
1292
|
+
// the tool_call event carrying the same item id.
|
|
1293
|
+
toolUseId: str(params, "itemId"),
|
|
1294
|
+
// FileChangeRequestApprovalParams carries `grantRoot`, never `path`,
|
|
1295
|
+
// so file-change approvals reached hosts with no description at all.
|
|
1296
|
+
description: str(params, "command")
|
|
1297
|
+
|| str(params, "grantRoot")
|
|
1298
|
+
|| str(params, "reason")
|
|
1299
|
+
|| undefined,
|
|
1300
|
+
// Which subagent is asking. The child thread id is also its
|
|
1301
|
+
// `background_task.taskId`, so a host can line the approval up with
|
|
1302
|
+
// the task it already renders. Absent for the root turn.
|
|
1303
|
+
...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
|
|
1091
1304
|
});
|
|
1092
1305
|
this.rpcResponse(id, { decision: resp.allow ? "accept" : "decline" });
|
|
1093
1306
|
} catch {
|
|
1094
1307
|
this.rpcResponse(id, { decision: "decline" });
|
|
1095
1308
|
}
|
|
1096
1309
|
|
|
1097
|
-
|
|
1310
|
+
restore();
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Handle `item/permissions/requestApproval`.
|
|
1315
|
+
*
|
|
1316
|
+
* Unlike the accept/decline approvals, the response here IS the grant: the
|
|
1317
|
+
* schema requires a `permissions` profile, so the generic "ack with `{}` to
|
|
1318
|
+
* unblock the turn" fallback this used to land in was schema-invalid.
|
|
1319
|
+
* Granting means echoing back the profile the agent asked for; refusing means
|
|
1320
|
+
* an empty profile, which is valid and grants nothing beyond what it already
|
|
1321
|
+
* has. With no host handler we refuse, because silently widening a sandbox is
|
|
1322
|
+
* not a safe default for an unattended session.
|
|
1323
|
+
*/
|
|
1324
|
+
private async handlePermissionsApproval(id: number, params: Record<string, unknown>): Promise<void> {
|
|
1325
|
+
const requestThreadId = str(params, "threadId") || null;
|
|
1326
|
+
const foreign = this.isForeignThread(requestThreadId);
|
|
1327
|
+
if (!foreign) this._state = "waiting_for_approval";
|
|
1328
|
+
const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_approval"); };
|
|
1329
|
+
const requested = asObj(params, "permissions");
|
|
1330
|
+
|
|
1331
|
+
if (!this.ctx.onUserInputRequest) {
|
|
1332
|
+
this.rpcResponse(id, { permissions: {} });
|
|
1333
|
+
restore();
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
const resp = await this.ctx.onUserInputRequest({
|
|
1339
|
+
toolName: "permissions",
|
|
1340
|
+
input: params,
|
|
1341
|
+
toolUseId: str(params, "itemId"),
|
|
1342
|
+
description: str(params, "reason") || undefined,
|
|
1343
|
+
...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
|
|
1344
|
+
});
|
|
1345
|
+
this.rpcResponse(id, { permissions: resp.allow ? requested : {} });
|
|
1346
|
+
} catch {
|
|
1347
|
+
this.rpcResponse(id, { permissions: {} });
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
restore();
|
|
1098
1351
|
}
|
|
1099
1352
|
|
|
1100
1353
|
/**
|
|
@@ -1104,9 +1357,17 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1104
1357
|
* back in Codex's `{ answers: { [questionId]: { answers: string[] } } }` shape.
|
|
1105
1358
|
*/
|
|
1106
1359
|
private async handleUserInputRequest(id: number, params: Record<string, unknown>): Promise<void> {
|
|
1360
|
+
// Same scoping as the approval handlers: a child subagent's question still
|
|
1361
|
+
// has to be answered, but it is not the root turn's business. Blocking root
|
|
1362
|
+
// state on it shows the session as waiting for input when the root turn is
|
|
1363
|
+
// running fine. `ToolRequestUserInputParams` requires `threadId`, so the
|
|
1364
|
+
// attribution is read from the wire rather than inferred.
|
|
1365
|
+
const requestThreadId = str(params, "threadId") || null;
|
|
1366
|
+
const foreign = this.isForeignThread(requestThreadId);
|
|
1107
1367
|
// Questions are user *input*, not a tool-permission gate — distinct state so a
|
|
1108
1368
|
// host UI can render a question form vs an approval prompt.
|
|
1109
|
-
this._state = "waiting_for_input";
|
|
1369
|
+
if (!foreign) this._state = "waiting_for_input";
|
|
1370
|
+
const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_input"); };
|
|
1110
1371
|
|
|
1111
1372
|
const questions = parseCodexQuestions(params);
|
|
1112
1373
|
|
|
@@ -1114,7 +1375,7 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1114
1375
|
// agent proceeds without hanging.
|
|
1115
1376
|
if (!this.ctx.onUserInputRequest || questions.length === 0) {
|
|
1116
1377
|
this.rpcResponse(id, { answers: {} });
|
|
1117
|
-
|
|
1378
|
+
restore();
|
|
1118
1379
|
return;
|
|
1119
1380
|
}
|
|
1120
1381
|
|
|
@@ -1122,14 +1383,16 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1122
1383
|
const resp = await this.ctx.onUserInputRequest({
|
|
1123
1384
|
toolName: "AskUserQuestion",
|
|
1124
1385
|
input: { questions },
|
|
1125
|
-
toolUseId: str(params, "
|
|
1386
|
+
toolUseId: str(params, "itemId") || "codex-user-input",
|
|
1387
|
+
// Which subagent is asking, matching its `background_task.taskId`.
|
|
1388
|
+
...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
|
|
1126
1389
|
});
|
|
1127
1390
|
this.rpcResponse(id, { answers: buildCodexUserInputAnswers(questions, resp) });
|
|
1128
1391
|
} catch {
|
|
1129
1392
|
this.rpcResponse(id, { answers: {} });
|
|
1130
1393
|
}
|
|
1131
1394
|
|
|
1132
|
-
|
|
1395
|
+
restore();
|
|
1133
1396
|
}
|
|
1134
1397
|
|
|
1135
1398
|
// -------------------------------------------------------------------------
|
|
@@ -1175,6 +1438,280 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1175
1438
|
return null;
|
|
1176
1439
|
}
|
|
1177
1440
|
|
|
1441
|
+
private backgroundTaskEvent(
|
|
1442
|
+
taskId: string,
|
|
1443
|
+
phase: "started" | "progress" | "completed",
|
|
1444
|
+
status: "running" | "completed" | "failed" | "stopped",
|
|
1445
|
+
options: {
|
|
1446
|
+
description?: string | null;
|
|
1447
|
+
summary?: string | null;
|
|
1448
|
+
parentTaskId?: string | null;
|
|
1449
|
+
turnId?: string | null;
|
|
1450
|
+
eventId?: string | null;
|
|
1451
|
+
raw: Record<string, unknown>;
|
|
1452
|
+
},
|
|
1453
|
+
): Extract<StreamEvent, { type: "background_task" }> {
|
|
1454
|
+
const rootThreadId = this._threadId ?? this._expectedThreadId;
|
|
1455
|
+
return {
|
|
1456
|
+
type: "background_task",
|
|
1457
|
+
taskId,
|
|
1458
|
+
taskType: "subagent",
|
|
1459
|
+
phase,
|
|
1460
|
+
status,
|
|
1461
|
+
description: options.description ?? null,
|
|
1462
|
+
summary: options.summary ?? null,
|
|
1463
|
+
parentTaskId: options.parentTaskId ?? null,
|
|
1464
|
+
timestamp: new Date().toISOString(),
|
|
1465
|
+
providerType: "codex",
|
|
1466
|
+
sessionId: rootThreadId,
|
|
1467
|
+
messageId: null,
|
|
1468
|
+
eventId: options.eventId ?? null,
|
|
1469
|
+
turnId: options.turnId ?? null,
|
|
1470
|
+
parentToolCallId: null,
|
|
1471
|
+
raw: options.raw,
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
private reconciledBackgroundTaskRaw(
|
|
1476
|
+
threadId: string,
|
|
1477
|
+
turn: Record<string, unknown> | null,
|
|
1478
|
+
): Record<string, unknown> {
|
|
1479
|
+
const error = turn ? str(asObj(turn, "error"), "message") || null : null;
|
|
1480
|
+
return {
|
|
1481
|
+
method: "thread/read",
|
|
1482
|
+
reconciled: true,
|
|
1483
|
+
threadId,
|
|
1484
|
+
turnId: turn ? str(turn, "id") || null : null,
|
|
1485
|
+
status: turn ? str(turn, "status") || null : null,
|
|
1486
|
+
error,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Codex 0.144+ represents collaboration as a root collabAgentToolCall item.
|
|
1492
|
+
* A completed spawn call only means the child was created. Register every
|
|
1493
|
+
* receiver and then follow the child thread itself for the real terminus.
|
|
1494
|
+
*/
|
|
1495
|
+
/** @returns whether the item produced at least one background-task edge. */
|
|
1496
|
+
private handleCollabAgentToolCall(
|
|
1497
|
+
item: Record<string, unknown>,
|
|
1498
|
+
raw: Record<string, unknown>,
|
|
1499
|
+
parentTaskId: string | null,
|
|
1500
|
+
): boolean {
|
|
1501
|
+
if (this._state === "closed" || !this.ctx.onEvent) return false;
|
|
1502
|
+
let dispatched = false;
|
|
1503
|
+
|
|
1504
|
+
const tool = str(item, "tool");
|
|
1505
|
+
const states = asObj(item, "agentsStates");
|
|
1506
|
+
const receiverIds = stringArray(item["receiverThreadIds"]);
|
|
1507
|
+
const taskIds = [...new Set([...receiverIds, ...Object.keys(states)])];
|
|
1508
|
+
const canStart = tool === "spawnAgent";
|
|
1509
|
+
const metadataOnly = tool === "resumeAgent" || tool === "sendInput";
|
|
1510
|
+
const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
|
|
1511
|
+
|
|
1512
|
+
for (const taskId of taskIds) {
|
|
1513
|
+
const previous = this._backgroundTasks.get(taskId);
|
|
1514
|
+
const description = str(item, "prompt") || previous?.description || null;
|
|
1515
|
+
const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
|
|
1516
|
+
|
|
1517
|
+
// The child thread's own turn/started notification is the only
|
|
1518
|
+
// authoritative reactivation edge. Root resume/send calls can arrive
|
|
1519
|
+
// before or after that child turn, so they only enrich an active task.
|
|
1520
|
+
if (metadataOnly) {
|
|
1521
|
+
if (!previous || previous.terminal) continue;
|
|
1522
|
+
if (description && description !== previous.description) {
|
|
1523
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
|
|
1524
|
+
description,
|
|
1525
|
+
summary: previous.summary,
|
|
1526
|
+
parentTaskId: resolvedParentTaskId,
|
|
1527
|
+
eventId: this._threadId
|
|
1528
|
+
? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
|
|
1529
|
+
: null,
|
|
1530
|
+
raw,
|
|
1531
|
+
}));
|
|
1532
|
+
dispatched = true;
|
|
1533
|
+
}
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (!previous && !canStart) continue;
|
|
1538
|
+
if (!canStart && !canStopWithoutState && !(taskId in states)) continue;
|
|
1539
|
+
|
|
1540
|
+
const hasState = taskId in states;
|
|
1541
|
+
const state = !hasState && canStopWithoutState
|
|
1542
|
+
? { terminal: true, status: "stopped" as const, summary: null }
|
|
1543
|
+
: codexAgentState(states[taskId]);
|
|
1544
|
+
|
|
1545
|
+
if (!previous) {
|
|
1546
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "started", "running", {
|
|
1547
|
+
description,
|
|
1548
|
+
summary: null,
|
|
1549
|
+
parentTaskId: resolvedParentTaskId,
|
|
1550
|
+
eventId: this._threadId
|
|
1551
|
+
? `codex:${this._threadId}:background-task:${taskId}:started`
|
|
1552
|
+
: null,
|
|
1553
|
+
raw,
|
|
1554
|
+
}));
|
|
1555
|
+
dispatched = true;
|
|
1556
|
+
} else if (previous.terminal) {
|
|
1557
|
+
continue;
|
|
1558
|
+
} else if (description && description !== previous.description) {
|
|
1559
|
+
// thread/started can beat the richer root collab item. Preserve one
|
|
1560
|
+
// start edge, then publish the prompt as ordinary progress metadata.
|
|
1561
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
|
|
1562
|
+
description,
|
|
1563
|
+
summary: previous.summary,
|
|
1564
|
+
parentTaskId: resolvedParentTaskId,
|
|
1565
|
+
eventId: this._threadId
|
|
1566
|
+
? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
|
|
1567
|
+
: null,
|
|
1568
|
+
raw,
|
|
1569
|
+
}));
|
|
1570
|
+
dispatched = true;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (state.terminal) {
|
|
1574
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", state.status, {
|
|
1575
|
+
description,
|
|
1576
|
+
summary: state.summary,
|
|
1577
|
+
parentTaskId: resolvedParentTaskId,
|
|
1578
|
+
eventId: this._threadId
|
|
1579
|
+
? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "state"}:completed`
|
|
1580
|
+
: null,
|
|
1581
|
+
raw,
|
|
1582
|
+
}));
|
|
1583
|
+
dispatched = true;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return dispatched;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
private waitForBackgroundTaskPoll(pollKey: string): Promise<void> {
|
|
1590
|
+
if (this._state === "closed") return Promise.resolve();
|
|
1591
|
+
return new Promise((resolve) => {
|
|
1592
|
+
const timer = setTimeout(() => {
|
|
1593
|
+
this._backgroundTaskPollTimers.delete(pollKey);
|
|
1594
|
+
resolve();
|
|
1595
|
+
}, BACKGROUND_TASK_POLL_INTERVAL_MS);
|
|
1596
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1597
|
+
this._backgroundTaskPollTimers.set(pollKey, { timer, resolve });
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
private startBackgroundTaskPoller(taskId: string): void {
|
|
1602
|
+
if (this._state === "closed") return;
|
|
1603
|
+
const task = this._backgroundTasks.get(taskId);
|
|
1604
|
+
if (!task || task.terminal) return;
|
|
1605
|
+
const existing = this._backgroundTaskPollers.get(taskId);
|
|
1606
|
+
if (existing?.generation === task.generation) return;
|
|
1607
|
+
|
|
1608
|
+
const generation = task.generation;
|
|
1609
|
+
const pollKey = `${taskId}:${generation}`;
|
|
1610
|
+
let poller!: Promise<void>;
|
|
1611
|
+
poller = this.pollBackgroundTask(taskId, generation, pollKey).finally(() => {
|
|
1612
|
+
if (this._backgroundTaskPollers.get(taskId)?.promise === poller) {
|
|
1613
|
+
this._backgroundTaskPollers.delete(taskId);
|
|
1614
|
+
}
|
|
1615
|
+
const pending = this._backgroundTaskPollTimers.get(pollKey);
|
|
1616
|
+
if (pending) clearTimeout(pending.timer);
|
|
1617
|
+
this._backgroundTaskPollTimers.delete(pollKey);
|
|
1618
|
+
});
|
|
1619
|
+
this._backgroundTaskPollers.set(taskId, { generation, promise: poller });
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
private async pollBackgroundTask(
|
|
1623
|
+
taskId: string,
|
|
1624
|
+
generation: number,
|
|
1625
|
+
pollKey: string,
|
|
1626
|
+
): Promise<void> {
|
|
1627
|
+
while (this._state !== "closed") {
|
|
1628
|
+
const task = this._backgroundTasks.get(taskId);
|
|
1629
|
+
if (!task || task.generation !== generation || task.terminal) return;
|
|
1630
|
+
|
|
1631
|
+
try {
|
|
1632
|
+
const response = await this.boundedRpcRequest("thread/read", {
|
|
1633
|
+
threadId: taskId,
|
|
1634
|
+
includeTurns: true,
|
|
1635
|
+
});
|
|
1636
|
+
if (this.observeBackgroundTaskThread(response, taskId, generation)) return;
|
|
1637
|
+
} catch {
|
|
1638
|
+
// A child can briefly be pendingInit before thread/read can load it.
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const current = this._backgroundTasks.get(taskId);
|
|
1642
|
+
if (!current || current.generation !== generation || current.terminal) return;
|
|
1643
|
+
await this.waitForBackgroundTaskPoll(pollKey);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/** Return true once thread/read proves that the child's latest turn ended. */
|
|
1648
|
+
private observeBackgroundTaskThread(
|
|
1649
|
+
response: Record<string, unknown>,
|
|
1650
|
+
taskId: string,
|
|
1651
|
+
generation: number,
|
|
1652
|
+
): boolean {
|
|
1653
|
+
const task = this._backgroundTasks.get(taskId);
|
|
1654
|
+
if (!task || task.generation !== generation || task.terminal) return true;
|
|
1655
|
+
|
|
1656
|
+
const thread = asObj(response, "thread");
|
|
1657
|
+
const turns = Array.isArray(thread["turns"])
|
|
1658
|
+
? thread["turns"].map(obj)
|
|
1659
|
+
: [];
|
|
1660
|
+
|
|
1661
|
+
const latestTurn = turns[turns.length - 1];
|
|
1662
|
+
if (!latestTurn) {
|
|
1663
|
+
const threadStatus = str(asObj(thread, "status"), "type");
|
|
1664
|
+
if (threadStatus !== "systemError") return false;
|
|
1665
|
+
const failureTurn = { status: "systemError", error: { message: "Subagent failed to initialize" } };
|
|
1666
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", "failed", {
|
|
1667
|
+
description: task.description,
|
|
1668
|
+
summary: "Subagent failed to initialize",
|
|
1669
|
+
parentTaskId: task.parentTaskId,
|
|
1670
|
+
eventId: this._threadId
|
|
1671
|
+
? `codex:${this._threadId}:background-task:${taskId}:system-error:completed`
|
|
1672
|
+
: null,
|
|
1673
|
+
raw: this.reconciledBackgroundTaskRaw(taskId, failureTurn),
|
|
1674
|
+
}));
|
|
1675
|
+
return true;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const nativeStatus = str(latestTurn, "status");
|
|
1679
|
+
if (nativeStatus === "inProgress" || !nativeStatus) return false;
|
|
1680
|
+
|
|
1681
|
+
const turnId = str(latestTurn, "id") || null;
|
|
1682
|
+
// The child turn/started notification is authoritative for a reactivated
|
|
1683
|
+
// generation. thread/read can briefly lag and still end at an older turn.
|
|
1684
|
+
if (task.activeTurnId && turnId !== task.activeTurnId) return false;
|
|
1685
|
+
|
|
1686
|
+
const items = Array.isArray(latestTurn["items"])
|
|
1687
|
+
? latestTurn["items"].map(obj)
|
|
1688
|
+
: [];
|
|
1689
|
+
let summary: string | null = null;
|
|
1690
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
1691
|
+
const item = items[index]!;
|
|
1692
|
+
if (str(item, "type") !== "agentMessage") continue;
|
|
1693
|
+
summary = this.agentMessageText(item);
|
|
1694
|
+
if (summary) break;
|
|
1695
|
+
}
|
|
1696
|
+
const errorMessage = str(asObj(latestTurn, "error"), "message") || null;
|
|
1697
|
+
const status = nativeStatus === "failed"
|
|
1698
|
+
? "failed"
|
|
1699
|
+
: nativeStatus === "interrupted" || nativeStatus === "cancelled"
|
|
1700
|
+
? "stopped"
|
|
1701
|
+
: "completed";
|
|
1702
|
+
this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", status, {
|
|
1703
|
+
description: task.description,
|
|
1704
|
+
summary: summary ?? errorMessage,
|
|
1705
|
+
parentTaskId: task.parentTaskId,
|
|
1706
|
+
turnId,
|
|
1707
|
+
eventId: this._threadId && turnId
|
|
1708
|
+
? `codex:${this._threadId}:background-task:${taskId}:${turnId}:completed`
|
|
1709
|
+
: null,
|
|
1710
|
+
raw: this.reconciledBackgroundTaskRaw(taskId, latestTurn),
|
|
1711
|
+
}));
|
|
1712
|
+
return true;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1178
1715
|
/**
|
|
1179
1716
|
* Maintain just enough child metadata to turn a later foreign-thread
|
|
1180
1717
|
* terminal notification into one provider-neutral task event. This reducer
|
|
@@ -1188,6 +1725,9 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1188
1725
|
// a task that already reached a terminal state. A later child turn/started
|
|
1189
1726
|
// explicitly reactivates the record below in handleForeignNotification.
|
|
1190
1727
|
if (previous?.terminal) return false;
|
|
1728
|
+
// collabAgentToolCall and child thread/started can both announce the same
|
|
1729
|
+
// spawn. The first edge is sufficient and gives hosts one durable start.
|
|
1730
|
+
if (previous && event.phase === "started") return false;
|
|
1191
1731
|
|
|
1192
1732
|
const description = event.description ?? previous?.description ?? null;
|
|
1193
1733
|
const summary = event.summary ?? previous?.summary ?? null;
|
|
@@ -1199,14 +1739,38 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1199
1739
|
event.summary = summary;
|
|
1200
1740
|
event.parentTaskId = parentTaskId;
|
|
1201
1741
|
|
|
1742
|
+
const terminal = event.phase === "completed";
|
|
1202
1743
|
this._backgroundTasks.set(event.taskId, {
|
|
1203
1744
|
taskId: event.taskId,
|
|
1745
|
+
// `description` survives termination: the reactivation path in
|
|
1746
|
+
// handleForeignNotification reads it precisely BECAUSE the record is
|
|
1747
|
+
// terminal, and dropping it lost a reactivated child's label forever.
|
|
1748
|
+
// It is one bounded string per child. `summary` is the unbounded one —
|
|
1749
|
+
// full agent messages, appended per item — and has no reader that
|
|
1750
|
+
// outlives the task, so it is released.
|
|
1204
1751
|
description,
|
|
1205
|
-
summary,
|
|
1752
|
+
summary: terminal ? null : summary,
|
|
1206
1753
|
parentTaskId,
|
|
1207
|
-
terminal
|
|
1754
|
+
terminal,
|
|
1755
|
+
generation: previous?.generation ?? 0,
|
|
1756
|
+
activeTurnId: terminal ? null : event.turnId ?? previous?.activeTurnId ?? null,
|
|
1208
1757
|
});
|
|
1758
|
+
// Kept past termination too: a grandchild can spawn after its parent has
|
|
1759
|
+
// finished, and lineage resolves through this index.
|
|
1209
1760
|
if (description) this._backgroundTaskIdsByPath.set(description, event.taskId);
|
|
1761
|
+
// Every registration route converges here, so this is the one place that
|
|
1762
|
+
// can guarantee a reconciliation poller exists. Registering by way of the
|
|
1763
|
+
// parser's subAgentActivity branch previously produced a task with no
|
|
1764
|
+
// safety net at all, wholly dependent on the child notification arriving.
|
|
1765
|
+
//
|
|
1766
|
+
// Correctness rests on `startBackgroundTaskPoller` and the poll loop both
|
|
1767
|
+
// re-reading the record and bailing when it is terminal — NOT on the
|
|
1768
|
+
// microtask. The deferral is an optimization: one wire item can emit
|
|
1769
|
+
// several edges in a tick (a collab spawn whose child is already finished
|
|
1770
|
+
// dispatches `started` then `completed`), and yielding first means the
|
|
1771
|
+
// common case declines before spawning a `thread/read` rather than after.
|
|
1772
|
+
// Do not remove the terminal checks on the grounds that this defers.
|
|
1773
|
+
if (!terminal) queueMicrotask(() => this.startBackgroundTaskPoller(event.taskId));
|
|
1210
1774
|
return true;
|
|
1211
1775
|
}
|
|
1212
1776
|
|
|
@@ -1229,7 +1793,23 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1229
1793
|
// App-server can publish child thread/started before the corresponding
|
|
1230
1794
|
// root subAgentActivity item. Register only descendants of this session,
|
|
1231
1795
|
// not unrelated foreign threads multiplexed by a future server version.
|
|
1232
|
-
if (parentThreadId !== rootThreadId && !parentTask)
|
|
1796
|
+
if (parentThreadId !== rootThreadId && !parentTask) {
|
|
1797
|
+
// Say so. A wire shape that omits parentThreadId takes this branch for
|
|
1798
|
+
// every child, which silently disables the whole registration and
|
|
1799
|
+
// reconciliation path — the safety net that exists precisely because
|
|
1800
|
+
// child notifications cannot be relied on. Degrading quietly here
|
|
1801
|
+
// hides the one failure this machinery was built to survive.
|
|
1802
|
+
if (!parentThreadId && this.ctx.onOutput) {
|
|
1803
|
+
try {
|
|
1804
|
+
void this.ctx.onOutput(
|
|
1805
|
+
"stderr",
|
|
1806
|
+
`agentex: codex child thread ${childThreadId} announced no parent thread id; `
|
|
1807
|
+
+ "background-task tracking and reconciliation are disabled for it\n",
|
|
1808
|
+
);
|
|
1809
|
+
} catch { /* swallow */ }
|
|
1810
|
+
}
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1233
1813
|
|
|
1234
1814
|
const source = asObj(thread, "source");
|
|
1235
1815
|
const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
|
|
@@ -1275,6 +1855,7 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1275
1855
|
if (!task.terminal) return;
|
|
1276
1856
|
task.terminal = false;
|
|
1277
1857
|
task.summary = null;
|
|
1858
|
+
task.generation += 1;
|
|
1278
1859
|
const turn = asObj(params, "turn");
|
|
1279
1860
|
const turnId = str(turn, "id") || str(params, "turnId") || null;
|
|
1280
1861
|
const rootThreadId = this._threadId ?? this._expectedThreadId;
|
|
@@ -1304,6 +1885,10 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1304
1885
|
if (method === "item/completed") {
|
|
1305
1886
|
const item = asObj(params, "item");
|
|
1306
1887
|
const itemType = str(item, "type");
|
|
1888
|
+
if (itemType === "collabAgentToolCall") {
|
|
1889
|
+
this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, childThreadId);
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1307
1892
|
if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
|
|
1308
1893
|
const summary = this.agentMessageText(item);
|
|
1309
1894
|
if (summary) task.summary = summary;
|
|
@@ -1344,11 +1929,19 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1344
1929
|
: null,
|
|
1345
1930
|
turnId,
|
|
1346
1931
|
parentToolCallId: null,
|
|
1347
|
-
raw:
|
|
1932
|
+
raw: {
|
|
1933
|
+
method,
|
|
1934
|
+
reconciled: false,
|
|
1935
|
+
threadId: childThreadId,
|
|
1936
|
+
turnId,
|
|
1937
|
+
status: nativeStatus || null,
|
|
1938
|
+
error: errorMessage || null,
|
|
1939
|
+
},
|
|
1348
1940
|
});
|
|
1349
1941
|
}
|
|
1350
1942
|
|
|
1351
1943
|
private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
|
|
1944
|
+
if (this._state === "closed") return;
|
|
1352
1945
|
// codex/event — legacy wrapper
|
|
1353
1946
|
if (method === "codex/event") {
|
|
1354
1947
|
const innerMsg = str(params, "msg");
|
|
@@ -1394,6 +1987,17 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1394
1987
|
if (method === "item/completed") {
|
|
1395
1988
|
this._state = "thinking";
|
|
1396
1989
|
this.extractSummaryFromItem(params);
|
|
1990
|
+
const item = asObj(params, "item");
|
|
1991
|
+
if (str(item, "type") === "collabAgentToolCall") {
|
|
1992
|
+
// Shapes we model become background_task edges. Anything that produced
|
|
1993
|
+
// no edge still reaches the host as a raw `unknown` event rather than
|
|
1994
|
+
// vanishing — that escape hatch is how unmodeled wire shapes stay
|
|
1995
|
+
// visible, and collab is exactly where new shapes keep appearing.
|
|
1996
|
+
if (!this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null)) {
|
|
1997
|
+
this.emitStreamEvent(rawLine);
|
|
1998
|
+
}
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
1397
2001
|
this.emitStreamEvent(rawLine);
|
|
1398
2002
|
return;
|
|
1399
2003
|
}
|
|
@@ -1434,6 +2038,7 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1434
2038
|
// -------------------------------------------------------------------------
|
|
1435
2039
|
|
|
1436
2040
|
private handleLegacyEvent(event: Record<string, unknown>, rawLine: string): void {
|
|
2041
|
+
if (this._state === "closed") return;
|
|
1437
2042
|
const type = str(event, "type");
|
|
1438
2043
|
const eventThreadId =
|
|
1439
2044
|
str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
|
|
@@ -1577,11 +2182,37 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1577
2182
|
}
|
|
1578
2183
|
|
|
1579
2184
|
private resolveTurn(): void {
|
|
2185
|
+
// Bind delivery to the turn that just terminated. Everything below this
|
|
2186
|
+
// point can yield (usage scan, event chain), and a send() arriving in the
|
|
2187
|
+
// meantime starts a new turn that must not be settled by this result.
|
|
2188
|
+
const delivering = this._activeTurnReady;
|
|
1580
2189
|
const resolvedModel = this._turnModel ?? this.model;
|
|
1581
2190
|
let usage = this._turnUsage && resolvedModel
|
|
1582
2191
|
? { [resolvedModel]: { inputTokens: this._turnUsage.inputTokens, outputTokens: this._turnUsage.outputTokens } }
|
|
1583
2192
|
: undefined;
|
|
1584
2193
|
|
|
2194
|
+
// Freeze this turn's outcome and clear the accumulators NOW, while we are
|
|
2195
|
+
// still synchronous with the terminal frame. Delivery reads the snapshot,
|
|
2196
|
+
// so a turn that starts during the async gap below neither inherits these
|
|
2197
|
+
// values nor has its own overwritten. Resetting inside beginActiveTurn()
|
|
2198
|
+
// instead would not work: the usage-scan path re-reads state after an
|
|
2199
|
+
// await, so the new turn's reset would eat the old turn's summary.
|
|
2200
|
+
const outcome: TurnOutcome = {
|
|
2201
|
+
summary: this._turnSummary,
|
|
2202
|
+
isError: this._turnIsError,
|
|
2203
|
+
errorMessage: this._turnErrorMessage,
|
|
2204
|
+
wasInterrupted: this._turnWasInterrupted,
|
|
2205
|
+
};
|
|
2206
|
+
this._turnSummary = null;
|
|
2207
|
+
this._turnUsage = null;
|
|
2208
|
+
this._turnModel = null;
|
|
2209
|
+
this._turnIsError = false;
|
|
2210
|
+
this._turnWasInterrupted = false;
|
|
2211
|
+
this._turnErrorMessage = null;
|
|
2212
|
+
// Captured before clearing: the usage-scan fallback below still needs it.
|
|
2213
|
+
const turnStartedAt = this._turnStartedAt;
|
|
2214
|
+
this._turnStartedAt = null;
|
|
2215
|
+
|
|
1585
2216
|
// Usage precedence: the `turn.completed` payload is authoritative when
|
|
1586
2217
|
// present (captured above into _turnUsage). Only when the stream carried no
|
|
1587
2218
|
// usage do we fall back to scanning Codex's on-disk session logs.
|
|
@@ -1591,8 +2222,8 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1591
2222
|
// latest turn or read partially-written totals. We therefore scan ONLY when
|
|
1592
2223
|
// there is no in-band usage, and never let a scan failure fail the turn
|
|
1593
2224
|
// (usage simply stays undefined). Prefer the in-band payload always.
|
|
1594
|
-
if (!usage &&
|
|
1595
|
-
const startedAt =
|
|
2225
|
+
if (!usage && turnStartedAt) {
|
|
2226
|
+
const startedAt = turnStartedAt;
|
|
1596
2227
|
const threadId = this._threadId ?? undefined;
|
|
1597
2228
|
// Fire-and-forget: scan logs then deliver result
|
|
1598
2229
|
void scanCodexSessionUsage({ startedAfter: startedAt, threadId }).then((scanned) => {
|
|
@@ -1600,22 +2231,26 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1600
2231
|
}).catch(() => {
|
|
1601
2232
|
// Non-fatal — usage stays undefined
|
|
1602
2233
|
}).finally(() => {
|
|
1603
|
-
void this.deliverTurnResult(usage);
|
|
2234
|
+
void this.deliverTurnResult(usage, delivering, outcome);
|
|
1604
2235
|
});
|
|
1605
2236
|
return;
|
|
1606
2237
|
}
|
|
1607
2238
|
|
|
1608
|
-
void this.deliverTurnResult(usage);
|
|
2239
|
+
void this.deliverTurnResult(usage, delivering, outcome);
|
|
1609
2240
|
}
|
|
1610
2241
|
|
|
1611
|
-
private async deliverTurnResult(
|
|
2242
|
+
private async deliverTurnResult(
|
|
2243
|
+
usage: Record<string, import("../../types.js").TokenUsage> | undefined,
|
|
2244
|
+
delivering: ActiveTurnReady | null,
|
|
2245
|
+
outcome: TurnOutcome,
|
|
2246
|
+
): Promise<void> {
|
|
1612
2247
|
const result: TurnResult = {
|
|
1613
|
-
summary:
|
|
2248
|
+
summary: outcome.summary,
|
|
1614
2249
|
usage,
|
|
1615
2250
|
costUsd: null,
|
|
1616
|
-
status:
|
|
1617
|
-
errorCode:
|
|
1618
|
-
errorMessage:
|
|
2251
|
+
status: outcome.wasInterrupted ? "aborted" : outcome.isError ? "failed" : "completed",
|
|
2252
|
+
errorCode: outcome.wasInterrupted ? "aborted" : outcome.isError ? "execution_error" : null,
|
|
2253
|
+
errorMessage: outcome.wasInterrupted ? "Turn was interrupted" : outcome.errorMessage,
|
|
1619
2254
|
};
|
|
1620
2255
|
|
|
1621
2256
|
// Drain pending onEvent handlers so callers awaiting send() see a settled
|
|
@@ -1631,22 +2266,29 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1631
2266
|
// session whose stdin is dead.
|
|
1632
2267
|
if (this._state === "closed") return;
|
|
1633
2268
|
|
|
1634
|
-
this
|
|
1635
|
-
|
|
1636
|
-
//
|
|
1637
|
-
//
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
this.
|
|
1646
|
-
|
|
1647
|
-
this.
|
|
1648
|
-
|
|
1649
|
-
|
|
2269
|
+
// A send() that landed while this delivery was suspended has already begun
|
|
2270
|
+
// a new turn and owns the session. Its result is still owed to it, so this
|
|
2271
|
+
// delivery settles only its OWN generation and touches neither the state
|
|
2272
|
+
// nor the interrupt latch the new turn needs to stay stoppable.
|
|
2273
|
+
//
|
|
2274
|
+
// `delivering === null` must take this branch too. A duplicate terminal
|
|
2275
|
+
// frame for an already-delivered turn (codex can report failure through
|
|
2276
|
+
// both `turn/completed` with a failed status and a separate `turn/failed`)
|
|
2277
|
+
// reaches here with a null latch, and exempting it restored every symptom
|
|
2278
|
+
// the fence exists to prevent: silent Stop, idle-while-running, and a
|
|
2279
|
+
// stale settle.
|
|
2280
|
+
const superseded = this._activeTurnReady !== delivering;
|
|
2281
|
+
|
|
2282
|
+
// Drain the pending send() resolvers belonging to this turn. Several
|
|
2283
|
+
// concurrent sends coalesced into one turn share the same TurnResult; a
|
|
2284
|
+
// send from a later turn is left in place for its own delivery.
|
|
2285
|
+
const pending = this.takePendingResults(delivering?.generation);
|
|
2286
|
+
|
|
2287
|
+
if (!superseded) {
|
|
2288
|
+
this._state = "idle";
|
|
2289
|
+
// Accumulators were already snapshotted and cleared in resolveTurn().
|
|
2290
|
+
this.clearActiveTurn(undefined, delivering ?? undefined);
|
|
2291
|
+
}
|
|
1650
2292
|
|
|
1651
2293
|
for (const p of pending) {
|
|
1652
2294
|
// Skip sends already settled early by timeout / abort.
|
|
@@ -1667,8 +2309,9 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1667
2309
|
// Pass current threadId so NDJSON-shaped events (via codex/event wrapper)
|
|
1668
2310
|
// carry sessionId. v2 notifications parse threadId from params directly
|
|
1669
2311
|
// and ignore this arg.
|
|
1670
|
-
const event
|
|
1671
|
-
|
|
2312
|
+
for (const event of parseCodexStreamLines(rawLine, this._threadId)) {
|
|
2313
|
+
this.dispatchEvent(event);
|
|
2314
|
+
}
|
|
1672
2315
|
}
|
|
1673
2316
|
|
|
1674
2317
|
/**
|