@runuai/host 0.9.169 → 0.9.171

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/lib/agent-cli.ts CHANGED
@@ -236,6 +236,7 @@ import { open, realpath } from "node:fs/promises";
236
236
  import { dirname, isAbsolute, join, relative, resolve, sep, basename } from "node:path";
237
237
  import { fileURLToPath } from "node:url";
238
238
  import { randomUUID, createHash } from "node:crypto";
239
+ import { spawnSync } from "node:child_process";
239
240
 
240
241
  const here = dirname(fileURLToPath(import.meta.url));
241
242
  let cfg = {};
@@ -249,6 +250,14 @@ if (!TOKEN || !API_URL) {
249
250
  process.exit(1);
250
251
  }
251
252
 
253
+ /** The task this agent's token names: its unsigned body, read for display
254
+ * only; the cloud verifies the token and resolves the id itself. */
255
+ function tokenTaskId() {
256
+ if (!TOKEN.startsWith("uai_")) return null;
257
+ try { return JSON.parse(Buffer.from(TOKEN.slice(4).split(".")[0], "base64url").toString("utf8")).t || null; }
258
+ catch { return null; }
259
+ }
260
+
252
261
  function parseFlags(args) {
253
262
  const flags = {}; const rest = [];
254
263
  for (let i = 0; i < args.length; i++) {
@@ -852,6 +861,23 @@ async function main() {
852
861
  must: Number(flags.must), should: Number(flags.should), nits: Number(flags.nits), verdict: flags.verdict,
853
862
  summary: flags.summary, assignmentId: flags.assignment, expectedRevision: Number(flags.revision || 0) })); return;
854
863
  }
864
+ if (group === "branch" && action === "name") {
865
+ // uai/<taskId>/<name>: this task's server-issued branch namespace. A PR
866
+ // from it links to this task exactly like one from the task branch.
867
+ const name = pos[0] || "";
868
+ const valid = name.length > 0 && name.length <= 150 && !name.includes("..") &&
869
+ name.split("/").every(part => /^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(part) && !part.endsWith(".lock") && !part.endsWith("."));
870
+ if (pos.length !== 1 || Object.keys(flags).length || !valid) {
871
+ console.error("uai: branch name <name> (letters, digits, '.', '_', '-' and '/'; no leading '-' or '.')"); process.exit(1);
872
+ }
873
+ const taskId = tokenTaskId();
874
+ if (!taskId || !/^[0-9a-hjkmnp-tv-z]{26}$/.test(taskId)) { console.error("uai: branch name: this task's id is unavailable"); process.exit(1); }
875
+ const branch = "uai/" + taskId + "/" + name;
876
+ // git's own rule, when git is here to ask.
877
+ const git = spawnSync("git", ["check-ref-format", "--branch", branch], { encoding: "utf8" });
878
+ if (!git.error && git.status !== 0) { console.error("uai: branch name: git refuses " + branch); process.exit(1); }
879
+ out(branch); return;
880
+ }
855
881
  if (group === "crew") {
856
882
  // ADR-157: the task's built-in Astronaut (the @astronaut Uai builds, never a
857
883
  // persona) changes its own crew. One agent per call, always with a --why.
@@ -1306,6 +1332,31 @@ async function main() {
1306
1332
  console.error("uai: task project add <projectId|slug> [--no-wait] | task project status <operationId>");
1307
1333
  process.exit(1);
1308
1334
  }
1335
+ case "task rename": {
1336
+ // The name is prose, not flags: read the raw tail, like todo text.
1337
+ const name = rest.join(" ").trim();
1338
+ if (!name || name.length > 120) { console.error('uai: task rename "<new name>" (1-120 characters)'); process.exit(1); }
1339
+ const r = await api("PATCH", "/api/agent/task", { name });
1340
+ out(r.changed ? "renamed the task to " + r.task.name + " (branch stays " + r.task.branch + ")" : "the task is already named " + r.task.name);
1341
+ break;
1342
+ }
1343
+ case "task label": {
1344
+ const sub = rest[0];
1345
+ // One shell argument is one label, literally: commas are valid in label
1346
+ // names, so "bug, urgent" is a single label and bug urgent is two.
1347
+ const names = rest.slice(1).map((n) => String(n).trim()).filter(Boolean);
1348
+ if ((sub !== "add" && sub !== "remove") || !names.length) { console.error('uai: task label add <name> [<name>…] | task label remove <name> [<name>…] (quote a name with spaces or commas)'); process.exit(1); }
1349
+ const r = await api(sub === "add" ? "POST" : "DELETE", "/api/agent/task/labels", { names });
1350
+ const changed = sub === "add" ? r.added : r.removed;
1351
+ out((changed.length ? (sub === "add" ? "added " : "removed ") + changed.join(", ") : "no change") + " — labels: " + (r.labels.map((l) => l.name).join(", ") || "none"));
1352
+ break;
1353
+ }
1354
+ case "task delete": {
1355
+ if (pos.length !== 1 || Object.keys(flags).length) { console.error("uai: task delete <childId> (a draft you created; close a started child instead)"); process.exit(1); }
1356
+ const r = await api("DELETE", "/api/agent/tasks/" + encodeURIComponent(pos[0]));
1357
+ out("deleted draft " + r.deleted);
1358
+ break;
1359
+ }
1309
1360
  case "task search": {
1310
1361
  const q = pos.join(" ") || flags.q || "";
1311
1362
  if (!q) { console.error("uai: task search <query>"); process.exit(1); }
@@ -1350,6 +1401,10 @@ async function main() {
1350
1401
  ' uai task send <childId> "text"',
1351
1402
  ' uai task answer <childId> <cardId> "option text"',
1352
1403
  ' uai task close [--child <childId>] --status finished|canceled [--reason "final report"]',
1404
+ " uai task delete <childId> (delete a draft you created for this task; started children are closed instead)",
1405
+ ' uai task rename "<new name>" (rename this running task; its branch keeps its name)',
1406
+ ' uai task label add <name> [<name>…] (label this running task; one argument per label, quote "a, b" to keep it whole; missing labels are created)',
1407
+ " uai task label remove <name> [<name>…]",
1353
1408
  " uai memory search <query> [--as handle] [--limit N|all]",
1354
1409
  " uai memory save <text> [--project id] [--tags a,b]",
1355
1410
  " uai memory delete <id>",
@@ -1358,6 +1413,7 @@ async function main() {
1358
1413
  " uai todo edit <#id> <text>",
1359
1414
  " uai todo remove <#id>",
1360
1415
  " uai todo claim|done|reopen|unclaim <#id>",
1416
+ ' uai branch name <name> (prints uai/<taskId>/<name>; a PR from it links to this task: git switch -c "$(uai branch name fix-foo)")',
1361
1417
  " uai review report --pr <number|URL> --head <full-sha> --must <n> --should <n> --nits <n> --verdict approve|changes [--summary text] [--assignment id] [--revision n]",
1362
1418
  " uai crew outcome @agent accepted|reworked|failed [--note text] [--assignment id] [--revision n]",
1363
1419
  " Review reports require an authorized exact-head assignment. Outcomes are Secretary assessments, never human acceptance.",
@@ -0,0 +1,22 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+
3
+ import { captureHostError, scrubHostString } from "./obs";
4
+
5
+ /** Provider errors can quote requests. Keep chat copy short and on one line;
6
+ * diagnostic reporting retains the full length with the host's redaction. */
7
+ export function agentErrorMessage(message: string): string {
8
+ const line = stripVTControlCharacters(scrubHostString(message))
9
+ .replace(/[\s\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
10
+ return line.length > 300 ? `${line.slice(0, 299).trimEnd()}…` : line;
11
+ }
12
+
13
+ export function reportAgentError(
14
+ message: string,
15
+ context: { taskId: string; agentId: string; engine: string; rpcCode?: number },
16
+ { capture = true }: { capture?: boolean } = {},
17
+ ): void {
18
+ const diagnostic = scrubHostString(message);
19
+ console.warn(`[agent] ${context.taskId}/${context.agentId}: ${diagnostic}`);
20
+ // This uses the existing disclosure/opt-out gate and Sentry scrubber.
21
+ if (capture) captureHostError(new Error(diagnostic), context);
22
+ }
@@ -13,7 +13,7 @@
13
13
  * initialized (notification) → sent by us
14
14
  * thread/start (request) → result `{ thread: { id, … } }`
15
15
  * turn/start (request) → output streams as notifications;
16
- * the response itself is ignored
16
+ * response/activity acknowledges acceptance
17
17
  *
18
18
  * Streaming notifications consumed (`mapCodexNotification`):
19
19
  * item/agentMessage/delta → message_delta
@@ -39,6 +39,7 @@ import { getDb, schema } from "../db";
39
39
  import { requireContainerRuntimeOperational } from "../runtime-guard";
40
40
  import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
41
41
  import { newId } from "../ulid";
42
+ import { agentErrorMessage, reportAgentError } from "../agent-errors";
42
43
  import { attachCompatibilityKeysMatch } from "./attach-compatibility";
43
44
  import { createAgentTransport, type LineTransport } from "./transport";
44
45
  import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
@@ -76,6 +77,10 @@ const CODEX_DEFAULT_EFFORT = "medium";
76
77
  export const CODEX_USAGE_WARNING_PERCENT = 90;
77
78
  export const CODEX_USAGE_LIMITED_PERCENT = 100;
78
79
  export const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
80
+ /** Only bounds acceptance, never the duration of an acknowledged turn. A
81
+ * minute allows cold starts/network setup while bounding false working state.
82
+ * Queued starts wait for accepted work to finish before this clock runs. */
83
+ export const CODEX_TURN_START_TIMEOUT_MS = 60_000;
79
84
 
80
85
  // ---------------------------------------------------------------------------
81
86
  // Pure protocol mapping — JSON-RPC notification → AgentEvent[].
@@ -580,8 +585,20 @@ export class CodexSession implements AgentSession {
580
585
  >();
581
586
  /** permission_request id → the JSON-RPC request id to answer with. */
582
587
  private readonly approvals = new Map<string, number | string>();
583
- /** Fire-and-forget turn/start ids whose error responses end turn polling. */
584
- private readonly turnStartRpcIds = new Set<number>();
588
+ /** Responses can follow notifications, so keep ids until completion. */
589
+ private readonly turnStarts = new Map<number, {
590
+ timer: ReturnType<typeof setTimeout> | null;
591
+ turnId: string | null;
592
+ accepted: boolean;
593
+ settles: number;
594
+ }>();
595
+ /** Merged request id → the canonical start, until its turn completes. */
596
+ private readonly mergedStarts = new Map<number, number>();
597
+ private readonly firedRequests = new Map<number, string>();
598
+ /** Live provider turns plus unacknowledged starts, excluding merged aliases. */
599
+ private outstandingTurns = 0;
600
+ private failed = false;
601
+ private cliVersion: string | null = null;
585
602
 
586
603
  private threadId: string | null = null;
587
604
  private readonly ready: Promise<void>;
@@ -668,20 +685,17 @@ export class CodexSession implements AgentSession {
668
685
  this.proc.onLine((line) => this.onLine(line));
669
686
  this.proc.onExit((code) => {
670
687
  if (this.closed) return;
671
- this.turnRunning = false;
672
- this.clearRateLimitsTimer();
673
- this.turnStartRpcIds.clear();
674
- if (code !== 0) {
688
+ if (!this.failed && (code !== 0 || this.outstandingTurns > 0)) {
675
689
  const tail = this.proc.stderrTail.trim();
676
- this.emit({
677
- type: "error",
678
- message:
679
- `codex process exited (${code ?? "spawn failed"})` +
690
+ this.failTurn(
691
+ (code === null ? `${this.cliLabel} process stopped unexpectedly` :
692
+ `${this.cliLabel} process exited (${code})${code === 0 ? " before replying to the message" : ""}`) +
680
693
  (tail
681
694
  ? `:\n${tail}`
682
- : " — is the codex CLI installed in the task container, and is the container running?"),
683
- });
695
+ : ". Please try again."),
696
+ );
684
697
  }
698
+ this.clearTurnState();
685
699
  this.closed = true;
686
700
  for (const waiter of this.pending.values()) {
687
701
  waiter.reject(new Error("codex process exited"));
@@ -691,12 +705,9 @@ export class CodexSession implements AgentSession {
691
705
  });
692
706
 
693
707
  this.ready = this.handshake().catch((err: unknown) => {
694
- this.emit({
695
- type: "error",
696
- message: `codex handshake failed: ${
708
+ this.failTurn(`${this.cliLabel} could not start: ${
697
709
  err instanceof Error ? err.message : String(err)
698
- }`,
699
- });
710
+ }`, (err as { rpcCode?: number }).rpcCode);
700
711
  });
701
712
  }
702
713
 
@@ -706,7 +717,7 @@ export class CodexSession implements AgentSession {
706
717
  // close() severs this generation before the durable runner necessarily
707
718
  // exits. Ignore buffered notifications so a late thread/started cannot
708
719
  // restore the resume id that a live roster edit just cleared.
709
- if (this.closed) return;
720
+ if (this.closed || this.failed) return;
710
721
  let msg: unknown;
711
722
  try {
712
723
  msg = JSON.parse(line);
@@ -720,10 +731,13 @@ export class CodexSession implements AgentSession {
720
731
  // Response to one of our requests (id, no method).
721
732
  if (hasId && !("method" in msg)) {
722
733
  const id = typeof msg.id === "number" ? msg.id : -1;
723
- if (this.turnStartRpcIds.delete(id)) {
734
+ const fired = this.firedRequests.get(id);
735
+ this.firedRequests.delete(id);
736
+ if (this.turnStarts.has(id) || this.mergedStarts.has(id)) {
724
737
  if (isObj(msg.error)) {
725
- this.turnRunning = false;
726
- this.clearRateLimitsTimer();
738
+ this.failRpc(msg.error);
739
+ } else {
740
+ this.acknowledgeTurn(id, msg.result);
727
741
  }
728
742
  return;
729
743
  }
@@ -743,6 +757,11 @@ export class CodexSession implements AgentSession {
743
757
  } else {
744
758
  waiter.resolve(msg.result);
745
759
  }
760
+ } else if (fired === "turn/interrupt" && isObj(msg.error)) {
761
+ const code = typeof msg.error.code === "number" ? msg.error.code : undefined;
762
+ const message = `Couldn't stop the message: ${this.cliLabel}${code === undefined ? "" : ` (code ${code})`}: ${str(msg.error.message, "Unknown error. Please try again.")}`;
763
+ this.reportError(message, code);
764
+ this.emit({ type: "notice", message: agentErrorMessage(message) });
746
765
  }
747
766
  return;
748
767
  }
@@ -760,6 +779,13 @@ export class CodexSession implements AgentSession {
760
779
  // Notification (method, no id).
761
780
  if ("method" in msg) {
762
781
  const method = str(msg.method);
782
+ // Account usage responses are not proof of a running turn. Turn/item
783
+ // activity (including an approval request below) is acceptance even on
784
+ // CLIs that defer the turn/start response until the turn ends.
785
+ if (method === "turn/started" || method.startsWith("item/") ||
786
+ method === "thread/tokenUsage/updated" || method === "turn/completed") {
787
+ this.acknowledgeActivity(msg.params, method === "turn/started");
788
+ }
763
789
  if (method === "thread/tokenUsage/updated") {
764
790
  this.turnUsage.observe(msg.params);
765
791
  }
@@ -767,10 +793,35 @@ export class CodexSession implements AgentSession {
767
793
  method === "turn/completed"
768
794
  ? this.turnUsage.complete(msg.params, this.actualModel)
769
795
  : undefined;
796
+ let settles = 1;
770
797
  if (method === "turn/completed") {
771
- this.turnRunning = false;
772
- this.clearRateLimitsTimer();
773
- this.turnStartRpcIds.clear();
798
+ if (isObj(msg.params) && isObj(msg.params.turn) &&
799
+ msg.params.turn.status === "failed" && isObj(msg.params.turn.error)) {
800
+ this.failRpc(msg.params.turn.error, completedUsage);
801
+ return;
802
+ }
803
+ this.outstandingTurns = Math.max(0, this.outstandingTurns - 1);
804
+ const turnId = this.notificationTurnId(msg.params);
805
+ const completed = [...this.turnStarts].find(([, turn]) => turnId !== null && turn.turnId === turnId)
806
+ ?? [...this.turnStarts].find(([, turn]) => turn.accepted);
807
+ if (completed) {
808
+ settles = completed[1].settles;
809
+ this.turnStarts.delete(completed[0]);
810
+ this.firedRequests.delete(completed[0]);
811
+ for (const [alias, owner] of this.mergedStarts) {
812
+ if (owner !== completed[0]) continue;
813
+ this.mergedStarts.delete(alias);
814
+ this.firedRequests.delete(alias);
815
+ }
816
+ }
817
+ this.turnRunning = this.outstandingTurns > 0;
818
+ if (!this.turnRunning) this.clearTurnState();
819
+ else this.reconcileAcceptanceTimers();
820
+ }
821
+ if (method === "error") {
822
+ const params = isObj(msg.params) ? msg.params : {};
823
+ this.failRpc(isObj(params.error) ? params.error : {});
824
+ return;
774
825
  }
775
826
  // The thread id arrives on `thread/started`; also captured from
776
827
  // the `thread/start` response in handshake().
@@ -790,7 +841,11 @@ export class CodexSession implements AgentSession {
790
841
  Date.now(),
791
842
  completedUsage,
792
843
  )) {
793
- this.emit(ev);
844
+ this.emit(ev.type === "turn_complete" ? {
845
+ ...ev,
846
+ ...(settles > 1 ? { settles } : {}),
847
+ ...(isObj(msg.params) && isObj(msg.params.turn) && msg.params.turn.status === "interrupted" ? { aborted: true } : {}),
848
+ } : ev);
794
849
  }
795
850
  }
796
851
  }
@@ -801,6 +856,7 @@ export class CodexSession implements AgentSession {
801
856
  params: unknown,
802
857
  ): void {
803
858
  if (APPROVAL_METHODS.has(method)) {
859
+ this.acknowledgeActivity(params);
804
860
  const permId = newId();
805
861
  this.approvals.set(permId, id);
806
862
  const p = isObj(params) ? params : {};
@@ -851,9 +907,10 @@ export class CodexSession implements AgentSession {
851
907
 
852
908
  /** Send a JSON-RPC request without awaiting it — for `turn/start`,
853
909
  * whose output streams as notifications and whose response can take
854
- * minutes. The eventual response is ignored (no pending waiter). */
910
+ * minutes. Acceptance/errors are handled separately, without a waiter. */
855
911
  private fireRequest(method: string, params: unknown): number {
856
912
  const id = this.nextRpcId++;
913
+ this.firedRequests.set(id, method);
857
914
  this.proc.writeLine({
858
915
  jsonrpc: "2.0",
859
916
  id,
@@ -880,7 +937,7 @@ export class CodexSession implements AgentSession {
880
937
  }
881
938
 
882
939
  private async handshake(): Promise<void> {
883
- await this.request("initialize", {
940
+ const initialized = await this.request("initialize", {
884
941
  clientInfo: { name: "uai", version: "0.2" },
885
942
  // ADR-078: unlock experimental fields. thread/resume's `excludeTurns`
886
943
  // (resume without replaying history — see tryResumeThread) is gated on
@@ -889,6 +946,12 @@ export class CodexSession implements AgentSession {
889
946
  // we fall back to a fresh thread, losing the whole conversation.
890
947
  capabilities: { experimentalApi: true },
891
948
  });
949
+ // The server's user agent starts with <client-name>/<CLI-version>; it
950
+ // does not use the clientInfo.version we sent (verified on codex 0.164).
951
+ if (isObj(initialized)) {
952
+ this.cliVersion = str(initialized.userAgent)
953
+ .match(/^[^/\s]+\/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\s|$)/)?.[1] ?? null;
954
+ }
892
955
  this.notify("initialized", {});
893
956
  // ADR-078: a thread id from a prior host process means this is a restart —
894
957
  // resume the SAME durable thread so the conversation survives, instead of
@@ -917,9 +980,13 @@ export class CodexSession implements AgentSession {
917
980
  : null;
918
981
  if (usage) this.emit({ type: "account_usage", usage });
919
982
  })
920
- .catch(() => {
983
+ .catch((error: unknown) => {
921
984
  // Usage is advisory. Authentication/turn errors still follow their
922
985
  // normal app-server notifications and must not be duplicated here.
986
+ if (!this.closed && !this.failed) {
987
+ this.reportError(`${this.cliLabel} usage read failed: ${error instanceof Error ? error.message : String(error)}`,
988
+ (error as { rpcCode?: number }).rpcCode, false);
989
+ }
923
990
  });
924
991
  }
925
992
 
@@ -1041,28 +1108,144 @@ export class CodexSession implements AgentSession {
1041
1108
  for (const h of this.handlers) h(event);
1042
1109
  }
1043
1110
 
1111
+ private get cliLabel(): string {
1112
+ return this.cliVersion ? `codex-cli ${this.cliVersion}` : "Codex";
1113
+ }
1114
+
1115
+ private notificationTurnId(params: unknown): string | null {
1116
+ if (!isObj(params)) return null;
1117
+ return str(params.turnId) || (isObj(params.turn) ? str(params.turn.id) : "") || null;
1118
+ }
1119
+
1120
+ private acknowledgeTurn(id: number, params?: unknown): void {
1121
+ const turn = this.turnStarts.get(id);
1122
+ if (!turn) return;
1123
+ if (turn.timer) clearTimeout(turn.timer);
1124
+ turn.timer = null;
1125
+ turn.accepted = true;
1126
+ turn.turnId = this.notificationTurnId(params) ?? turn.turnId;
1127
+ const owner = turn.turnId === null ? undefined : [...this.turnStarts].find(
1128
+ ([otherId, other]) => otherId !== id && other.accepted && other.turnId === turn.turnId,
1129
+ );
1130
+ if (owner) {
1131
+ // Codex 0.164 steers a mid-turn start into the existing turn. One
1132
+ // provider completion must settle every logical delivery it absorbed.
1133
+ owner[1].settles += turn.settles;
1134
+ this.turnStarts.delete(id);
1135
+ this.outstandingTurns = Math.max(0, this.outstandingTurns - 1);
1136
+ for (const [alias, target] of this.mergedStarts) {
1137
+ if (target === id) this.mergedStarts.set(alias, owner[0]);
1138
+ }
1139
+ this.mergedStarts.set(id, owner[0]);
1140
+ }
1141
+ this.reconcileAcceptanceTimers();
1142
+ }
1143
+
1144
+ private acknowledgeActivity(params: unknown, started = false): void {
1145
+ const turnId = this.notificationTurnId(params);
1146
+ if (turnId && [...this.turnStarts.values()].some(turn => turn.turnId === turnId)) {
1147
+ // Ordinary chunks still belong to the running turn. Only an explicit
1148
+ // new start notification can identify another start as merged here.
1149
+ const pending = started && [...this.turnStarts].find(([, turn]) => !turn.accepted);
1150
+ if (pending) this.acknowledgeTurn(pending[0], params);
1151
+ return;
1152
+ }
1153
+ // Associate the first activity with an already acknowledged start before
1154
+ // attributing it to a newer queued request. Old output must not cancel a
1155
+ // different request's watchdog.
1156
+ const unknown = [...this.turnStarts].find(([, turn]) => turn.turnId === null && turn.accepted);
1157
+ if (unknown) {
1158
+ if (turnId) unknown[1].turnId = turnId;
1159
+ return;
1160
+ }
1161
+ const pending = [...this.turnStarts].find(([, turn]) => !turn.accepted);
1162
+ if (pending) this.acknowledgeTurn(pending[0], params);
1163
+ }
1164
+
1165
+ private reconcileAcceptanceTimers(): void {
1166
+ // A late turn/start response can be queued behind accepted work on this
1167
+ // thread. Bound waiting only when the thread is free, and give queued
1168
+ // starts a fresh deadline when the last accepted message completes.
1169
+ const occupied = [...this.turnStarts.values()].some(turn => turn.accepted);
1170
+ for (const turn of this.turnStarts.values()) {
1171
+ if (occupied) {
1172
+ if (turn.timer) clearTimeout(turn.timer);
1173
+ turn.timer = null;
1174
+ } else if (!turn.timer) {
1175
+ turn.timer = setTimeout(() => {
1176
+ this.failTurn(`${this.cliLabel} didn't start this message within 60 seconds. Please try again.`);
1177
+ }, CODEX_TURN_START_TIMEOUT_MS);
1178
+ turn.timer.unref?.();
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ private clearTurnState(): void {
1184
+ this.turnRunning = false;
1185
+ this.outstandingTurns = 0;
1186
+ this.clearRateLimitsTimer();
1187
+ for (const turn of this.turnStarts.values()) if (turn.timer) clearTimeout(turn.timer);
1188
+ this.turnStarts.clear();
1189
+ this.mergedStarts.clear();
1190
+ this.firedRequests.clear();
1191
+ }
1192
+
1193
+ private failRpc(error: Record<string, unknown>, usage?: AgentUsage): void {
1194
+ const code = typeof error.code === "number" ? error.code : undefined;
1195
+ this.failTurn(`${this.cliLabel} rejected the message${code === undefined ? "" : ` (code ${code})`}: ${str(error.message, "Unknown error. Please try again.")}`, code, usage);
1196
+ }
1197
+
1198
+ private reportError(message: string, rpcCode?: number, capture = true): void {
1199
+ reportAgentError(message, { taskId: this.taskId, agentId: this.agentId, engine: this.kind, rpcCode }, { capture });
1200
+ }
1201
+
1202
+ /** One terminal failure per generation. Late responses, completion and
1203
+ * process exit cannot end this turn twice or leak into a replacement. */
1204
+ private failTurn(message: string, code?: number, usage?: AgentUsage): void {
1205
+ if (this.failed || this.closed) return;
1206
+ this.failed = true;
1207
+ this.reportError(message, code);
1208
+ const aborted = this.outstandingTurns > 0;
1209
+ const settles = this.outstandingTurns + [...this.turnStarts.values()]
1210
+ .reduce((merged, turn) => merged + turn.settles - 1, 0);
1211
+ this.clearTurnState();
1212
+ const display = agentErrorMessage(message);
1213
+ for (const event of mapCodexNotification("error", { error: { message } })) {
1214
+ this.emit(event.type === "error" ? { ...event, message: display,
1215
+ ...(display === message ? {} : { diagnosticMessage: message }), code, aborted } : event);
1216
+ }
1217
+ if (aborted) this.emit({ type: "turn_complete", aborted: true,
1218
+ ...(settles > 1 ? { settles } : {}), ...(usage ? { usage } : {}) });
1219
+ for (const waiter of this.pending.values()) waiter.reject(new Error(message));
1220
+ this.pending.clear();
1221
+ void this.proc.close().catch(() => {});
1222
+ }
1223
+
1044
1224
  async send(text: string): Promise<void> {
1045
- if (this.closed) return;
1225
+ if (this.closed || this.failed) throw new Error(`${this.cliLabel} is no longer available. Please try again.`);
1226
+ this.outstandingTurns += 1;
1046
1227
  await this.ready;
1047
- if (this.closed) return;
1228
+ if (this.closed || this.failed) return;
1048
1229
  if (!this.handshakeOk || !this.threadId) {
1049
- this.emit({
1050
- type: "error",
1051
- message:
1052
- "codex is unavailable — the app-server handshake did not complete.",
1053
- });
1230
+ this.failTurn(`${this.cliLabel} could not start. Please try again.`);
1054
1231
  return;
1055
1232
  }
1056
1233
 
1057
1234
  // `ready` may have waited across a runtime generation change. Recheck at
1058
1235
  // the turn/start write rather than trusting session construction time.
1059
- requireContainerRuntimeOperational();
1236
+ try {
1237
+ requireContainerRuntimeOperational();
1238
+ } catch (error) {
1239
+ this.outstandingTurns -= 1;
1240
+ throw error;
1241
+ }
1060
1242
  const turnStartId = this.fireRequest("turn/start", {
1061
1243
  threadId: this.threadId,
1062
1244
  input: [{ type: "text", text }],
1063
1245
  });
1064
- this.turnStartRpcIds.add(turnStartId);
1065
1246
  this.turnRunning = true;
1247
+ this.turnStarts.set(turnStartId, { timer: null, turnId: null, accepted: false, settles: 1 });
1248
+ this.reconcileAcceptanceTimers();
1066
1249
  this.scheduleRateLimitsRead();
1067
1250
  }
1068
1251
 
@@ -1093,9 +1276,7 @@ export class CodexSession implements AgentSession {
1093
1276
  return;
1094
1277
  }
1095
1278
  this.closed = true;
1096
- this.turnRunning = false;
1097
- this.clearRateLimitsTimer();
1098
- this.turnStartRpcIds.clear();
1279
+ this.clearTurnState();
1099
1280
  for (const waiter of this.pending.values()) {
1100
1281
  waiter.reject(new Error("session closed"));
1101
1282
  }
@@ -451,14 +451,20 @@ export class MachineDurableProcess implements LineTransport {
451
451
  if (this.#debug) this.#log(`-> ${json.slice(0, 1000)}`);
452
452
  this.#inboxChain = this.#inboxChain
453
453
  .then(() =>
454
- appendEnvironmentFile(
454
+ this.#closed || this.#detached ? undefined : appendEnvironmentFile(
455
455
  this.#environment,
456
456
  this.#inboxPath,
457
457
  `${inputMeta}\n${json}\n`,
458
458
  ),
459
459
  )
460
460
  .catch(() => {
461
- // Transport error — liveness probes will surface a dead session.
461
+ if (this.#closed || this.#detached) return;
462
+ // A fresh heartbeat proves the runner lives, not that it received the
463
+ // message. Fail immediately through the adapter's normal error path.
464
+ // Never include the frame or exec stderr: either may contain secrets.
465
+ this.#stderrBuf = "Could not deliver the message to the agent because its input could not be written. Please try again.";
466
+ this.#finish(null, "inbox append failed");
467
+ void this.#killRemoteRunner();
462
468
  });
463
469
  }
464
470
 
@@ -152,18 +152,32 @@ export type AgentEvent =
152
152
  | { type: "permission_request"; id: string; title: string; detail: string }
153
153
  /** The agent addressed another agent — uai routes this as a peer message. */
154
154
  | { type: "peer_message"; toAgentId: string; text: string }
155
- /** The turn (one request → response cycle) is done; agent is idle.
155
+ /** The provider turn is done; it may have absorbed multiple deliveries.
156
156
  * `usage` carries this turn's token/cost accounting when the CLI reports it. */
157
- | { type: "turn_complete"; usage?: AgentUsage }
157
+ | {
158
+ type: "turn_complete";
159
+ usage?: AgentUsage;
160
+ aborted?: boolean;
161
+ /** Logical deliveries combined into this one provider turn (default 1). */
162
+ settles?: number;
163
+ }
158
164
  /** Account-level capacity reported by the engine CLI. This never contains
159
165
  * credentials or provider account identifiers. */
160
166
  | { type: "account_usage"; usage: AgentAccountUsage }
167
+ /** A nonterminal operation failed; leave the current message running. */
168
+ | { type: "notice"; message: string }
161
169
  /** A recoverable error surfaced by the agent. `retryable` marks errors the
162
170
  * orchestrator can fail over from — "rate_limit" triggers account rotation
163
171
  * (ADR-076). */
164
172
  | {
165
173
  type: "error";
166
174
  message: string;
175
+ /** Host-only full diagnostic for classification. Never put on the wire. */
176
+ diagnosticMessage?: string;
177
+ /** Provider RPC error code, when supplied. */
178
+ code?: number;
179
+ /** The adapter has ended the accepted turn, including before output. */
180
+ aborted?: boolean;
167
181
  retryable?: "rate_limit";
168
182
  /** Parsed from the same rate-limit error text, when unambiguous. */
169
183
  resetsAt?: number;