@agent-compose/sdk 0.8.1 → 0.8.2

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.
@@ -24,7 +24,7 @@
24
24
  * values set via `agentc secrets set` are visible to the CLI.
25
25
  */
26
26
 
27
- import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxCommandResult, SandboxProvider, ToolCallGateResult } from "../index.js";
27
+ import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider, ToolCallGateResult } from "../index.js";
28
28
  import { defineRuntime } from "../types/runtime.js";
29
29
  import { AsyncQueue } from "../agent/async-queue.js";
30
30
  import { formatError } from "../utils/errors.js";
@@ -35,12 +35,65 @@ import { RequestContext } from "../request-context/request-context.js";
35
35
  import { AcpClientPeer, ACP_PROTOCOL_VERSION } from "./_acp-client.js";
36
36
  import { isPauseSignal, boundProcessorPause } from "../pause/pause-core.js";
37
37
  import {
38
- JSONL_GUARD_LINE_CAP_BYTES, JSONL_GUARD_KEEP_BYTES,
38
+ JSONL_GUARD_LINE_CAP_BYTES, JSONL_GUARD_KEEP_BYTES, JSONL_GUARD_NO_SENTINEL_EXIT,
39
39
  jsonlGuardScript, wrapJsonlCommand,
40
40
  } from "./_jsonl-guard.js";
41
+ import type { SandboxBackgroundProcess } from "../types/sandbox.js";
41
42
 
42
43
  function now(): string { return new Date().toISOString(); }
43
44
 
45
+ /** Backoff between tail re-attach attempts after a mid-turn stream fault
46
+ * (durable detached transport). Short: the runner is alive and producing,
47
+ * and each retry costs one exec; the executor's evidence machinery — not
48
+ * this loop — bounds a truly dead sandbox. Exported for tests. */
49
+ export const TAIL_REATTACH_DELAY_MS = 2_000;
50
+
51
+ /** Provider deadline on the detached LAUNCH exec. The launch shell only
52
+ * truncates the durable files, forks the detached runner (stdio fully
53
+ * redirected — see the launch command), writes the pidfile, and echoes the
54
+ * pid — sub-second work, so 30s is generous headroom for a slow envd, not a
55
+ * budget the runner ever consumes. Exported for tests. */
56
+ export const LAUNCH_EXEC_TIMEOUT_MS = 30_000;
57
+
58
+ /** How many times (and how spaced) a THROWN launch exec falls back to reading
59
+ * the durable pidfile before the fault is surfaced. A deadlined/canceled
60
+ * launch STREAM does not mean the launch failed — the detached tree may be
61
+ * up and working — so the pid is recovered over the envd HTTP file
62
+ * transport (immune to the command-stream fault) and the turn proceeds.
63
+ * Only "no pidfile after these attempts" is a real launch failure.
64
+ * Exported for tests. */
65
+ export const LAUNCH_PID_RECOVERY_ATTEMPTS = 3;
66
+ export const LAUNCH_PID_RECOVERY_DELAY_MS = 1_000;
67
+
68
+ /** Explicit deadline on the `command -v` install probe. E2B's default command
69
+ * deadline (60s) throws the deadline_exceeded TimeoutError — every provider
70
+ * call in the turn path carries an explicit timeout so no SDK default can
71
+ * decide a turn's fate. Exported for tests. */
72
+ export const INSTALL_PROBE_TIMEOUT_MS = 30_000;
73
+
74
+ /** Parse a pid out of captured stdout (last non-empty line). NaN when absent. */
75
+ function parsePid(raw: string): number {
76
+ const pid = Number.parseInt(raw.trim().split("\n").pop() ?? "", 10);
77
+ return Number.isInteger(pid) && pid > 0 ? pid : Number.NaN;
78
+ }
79
+
80
+ /** Recover the detached runner's pid from its durable pidfile after the
81
+ * launch exec's STREAM died (deadline_exceeded, canceled context, any wire
82
+ * fault). Reads over the file transport with a short retry so the race
83
+ * where the launch shell is still writing the pidfile is absorbed. */
84
+ async function recoverLaunchPid(read: (path: string) => Promise<string>, pidPath: string): Promise<number> {
85
+ for (let attempt = 0; attempt < LAUNCH_PID_RECOVERY_ATTEMPTS; attempt++) {
86
+ try {
87
+ const pid = parsePid(await read(pidPath));
88
+ if (!Number.isNaN(pid)) return pid;
89
+ } catch { /* file plane briefly unreachable — retried */ }
90
+ if (attempt < LAUNCH_PID_RECOVERY_ATTEMPTS - 1) {
91
+ await new Promise((r) => setTimeout(r, LAUNCH_PID_RECOVERY_DELAY_MS));
92
+ }
93
+ }
94
+ return Number.NaN;
95
+ }
96
+
44
97
  /** Single-quote a value for safe interpolation into a `sh -c` command line. */
45
98
  export function shellQuote(value: string): string {
46
99
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -302,7 +355,8 @@ export class CliAgentRunner implements ModelExecutionContract {
302
355
  * install command runs exactly once: on the first run of a content hash. */
303
356
  private async ensureInstalled(): Promise<void> {
304
357
  if (this.installed) return;
305
- const probe = await this.sandbox.commands.run(`command -v ${this.spec.bin}`);
358
+ const probe = await this.sandbox.commands.run(
359
+ `command -v ${this.spec.bin}`, { timeoutMs: INSTALL_PROBE_TIMEOUT_MS });
306
360
  if (probe.exitCode === 0) { this.installed = true; return; }
307
361
  const res = await this.sandbox.commands.run(this.spec.install, { timeoutMs: 300_000 });
308
362
  if (res.exitCode !== 0) {
@@ -640,53 +694,224 @@ export class CliAgentRunner implements ModelExecutionContract {
640
694
  keepBytes: JSONL_GUARD_KEEP_BYTES,
641
695
  });
642
696
 
643
- // Bridge the streaming stdout callback into an async-iterable of complete
644
- // JSONL lines. `onStdout` chunks aren't line-aligned, so buffer + split.
697
+ // Bridge the transport's output into an async-iterable of complete
698
+ // JSONL lines. Two transports feed it (chosen below):
699
+ //
700
+ // - DURABLE DETACHED (providers with `runBackground` + `files.read`,
701
+ // i.e. E2B — every cloud session): the CLI runs as a DETACHED
702
+ // in-guest process whose stdout is a durable file; the server only
703
+ // ever TAILS that file, and a dead tail is re-attached (or the file
704
+ // re-read over the envd HTTP file transport) without the runner
705
+ // noticing. The exit code travels IN-BAND as a final sentinel line.
706
+ // This exists because of the 2026-08-14 prod incident (conversation
707
+ // 8bc082f0, turn bfcb318f): the turn's single streaming exec was the
708
+ // CLI's lifeline — when the connect-web stream's context was
709
+ // canceled at the wire 50s in (envd logged `context canceled`), envd
710
+ // tore down the exec's stdout, the guard died on EPIPE, the CLI took
711
+ // SIGPIPE mid-work, the finished output was lost, and `wait()` never
712
+ // settled — the turn wedged silently until the next dispatch
713
+ // superseded it as a phantom "session restarted".
714
+ //
715
+ // - SINGLE-EXEC STREAMING (everything else: local child_process,
716
+ // Vercel): the pre-incident shape — one exec, stdout streamed,
717
+ // exit code from the exec result.
645
718
  const lines = new AsyncQueue<string>();
646
- let buf = "";
647
- const onStdout = (data: string) => {
648
- buf += data;
649
- let nl: number;
650
- while ((nl = buf.indexOf("\n")) >= 0) {
651
- const line = buf.slice(0, nl).trim();
652
- buf = buf.slice(nl + 1);
653
- if (line) lines.push(line);
654
- }
655
- };
656
- const runOpts = {
657
- ...(this.options.cwd ? { cwd: this.options.cwd } : {}),
658
- onStdout,
659
- timeoutMs: 0, // no provider deadline — see the doc comment above
660
- };
661
-
662
- // The command resolves when the process exits. Kick it off (don't await
663
- // yet); flush the trailing buffer + close the queue on completion so the
664
- // for-await below drains and we can read the exit code. Prefer the
665
- // kill-capable background handle so abort/early-exit can terminate the
666
- // process itself, not just stop reading its stream.
667
- let exited = false;
668
- const settle = (res: SandboxCommandResult): SandboxCommandResult => {
669
- exited = true;
670
- const tail = buf.trim();
671
- if (tail) lines.push(tail);
672
- lines.close();
673
- return res;
674
- };
675
- const fail = (err: unknown): never => { exited = true; lines.close(); throw err; };
676
- let kill: (() => Promise<void>) | undefined;
677
- let runPromise: Promise<SandboxCommandResult>;
719
+ // How the turn ended: the CLI's exit code once known (null while
720
+ // running), plus a best-effort stderr tail for the error message.
721
+ let exitCode: number | null = null;
722
+ let exitStderr = "";
723
+ let transportError: unknown = null;
724
+ let consumerStopped = false;
725
+ let reap: () => void = () => { /* set per transport */ };
726
+ let transport: Promise<void>;
727
+
728
+ const durableRead = this.sandbox.files.read?.bind(this.sandbox.files);
678
729
  if (this.sandbox.commands.runBackground) {
679
- const handle = await this.sandbox.commands.runBackground(guardedCmd, runOpts);
680
- kill = () => handle.kill();
681
- runPromise = handle.wait().then(settle, fail);
730
+ // ── Durable detached transport ─────────────────────────────────────
731
+ const outPath = `${promptPath}.out`;
732
+ const errPath = `${promptPath}.err`;
733
+ // The exit sentinel is appended by the DETACHED script itself after
734
+ // the guarded pipeline exits, so it lands in the durable file even if
735
+ // every server↔sandbox stream is dead by then. In node-guard mode the
736
+ // pipeline's exit code is already the CLI's (the guard re-raises the
737
+ // inner sentinel, including NO_SENTINEL_EXIT for a mid-pipe death).
738
+ const detachedScript =
739
+ `{ ${guardedCmd}; } >> ${shellQuote(outPath)} 2>> ${shellQuote(errPath)} </dev/null; `
740
+ + `printf '\\n%s %s\\n' ${shellQuote(sentinel)} "$?" >> ${shellQuote(outPath)}`;
741
+ // `setsid` detaches the runner into its own session/process group so
742
+ // it survives the launch exec ending AND gives `kill -- -pid` a whole
743
+ // tree to terminate. The fallback keeps working where setsid is
744
+ // absent; a runner that dies anyway surfaces as NO_SENTINEL below.
745
+ //
746
+ // The `>/dev/null 2>&1 </dev/null` on the forked child is LOAD-BEARING,
747
+ // not hygiene (2026-08-14 prod regression, v0.10.39): without it the
748
+ // detached `sh` inherits the LAUNCH exec's stdout/stderr pipes, and
749
+ // envd only ends a command's event stream on pipe EOF — so the launch
750
+ // exec's wait() couldn't settle until the entire detached CLI tree
751
+ // exited, the 30s launch deadline fired first, and EVERY turn died
752
+ // with E2B's `[deadline_exceeded] the operation timed out: … exceeding
753
+ // 'timeoutMs' …` while the orphaned runner kept working unobserved.
754
+ // (The runner's real stdio is redirected INSIDE detachedScript — into
755
+ // the durable out/err files — so the child needs nothing from the
756
+ // launch exec's pipes.)
757
+ //
758
+ // The pid ALSO lands in a durable pidfile so a launch whose STREAM
759
+ // dies (the same wire faults the tail survives) is recovered over the
760
+ // file transport instead of surfacing as turn death — see the catch
761
+ // below.
762
+ const pidPath = `${promptPath}.pid`;
763
+ const launchCmd =
764
+ `: > ${shellQuote(outPath)}; : > ${shellQuote(errPath)}; `
765
+ + `if command -v setsid >/dev/null 2>&1; then setsid sh -c ${shellQuote(detachedScript)} >/dev/null 2>&1 </dev/null & `
766
+ + `else sh -c ${shellQuote(detachedScript)} >/dev/null 2>&1 </dev/null & fi; `
767
+ + `echo "$!" > ${shellQuote(pidPath)}; echo "$!"`;
768
+ let pid = Number.NaN;
769
+ let launched: { exitCode: number; stdout?: string; stderr?: string } | null = null;
770
+ let launchErr: unknown = null;
771
+ try {
772
+ launched = await this.sandbox.commands.run(launchCmd, {
773
+ ...(this.options.cwd ? { cwd: this.options.cwd } : {}), timeoutMs: LAUNCH_EXEC_TIMEOUT_MS });
774
+ } catch (err) { launchErr = err; }
775
+ if (launched) {
776
+ // The exec ROUND-TRIPPED: its exit code and stdout are authoritative.
777
+ pid = parsePid(launched.stdout ?? "");
778
+ if (launched.exitCode !== 0 || Number.isNaN(pid)) {
779
+ throw new Error(`failed to launch ${this.spec.kind}: `
780
+ + `exit ${launched.exitCode}${launched.stderr ? `: ${launched.stderr.slice(-500)}` : ""}`);
781
+ }
782
+ } else {
783
+ // Structural rule of this transport: a dead STREAM is never treated
784
+ // as a dead RUNNER. The launch may well have run to completion with
785
+ // only its exec stream lost (deadline_exceeded, canceled context) —
786
+ // recover the pid from the durable pidfile and continue; only an
787
+ // absent pidfile makes the original fault real.
788
+ pid = durableRead ? await recoverLaunchPid(durableRead, pidPath) : Number.NaN;
789
+ if (Number.isNaN(pid)) throw launchErr;
790
+ console.warn(`[cli-agent] ${this.spec.kind} launch exec stream died but the detached runner is up `
791
+ + `(pid ${pid} via ${pidPath}) — continuing on the durable transport: ${formatError(launchErr)}`);
792
+ }
793
+
794
+ // Bytes of COMPLETE lines already consumed from outPath — a re-attach
795
+ // tails from here, so a dead stream never duplicates or drops lines.
796
+ let offset = 0;
797
+ let currentTail: SandboxBackgroundProcess | null = null;
798
+ const consumeLine = (raw: string): void => {
799
+ offset += Buffer.byteLength(raw, "utf8") + 1;
800
+ const line = raw.trim();
801
+ if (!line) return;
802
+ if (line.startsWith(sentinel)) {
803
+ const code = Number(line.slice(sentinel.length).trim());
804
+ exitCode = Number.isFinite(code) ? code : 0;
805
+ return;
806
+ }
807
+ lines.push(line);
808
+ };
809
+
810
+ reap = () => {
811
+ // Kill the detached tree (group first — setsid made pid the group
812
+ // leader), then whatever tail is currently attached. Fresh execs,
813
+ // deliberately independent of any possibly-dead stream.
814
+ void this.sandbox.commands.run(
815
+ `kill -TERM -- -${pid} 2>/dev/null; kill -TERM ${pid} 2>/dev/null; true`,
816
+ { timeoutMs: 10_000 }).catch(() => { /* best-effort */ });
817
+ void currentTail?.kill().catch(() => { /* already gone */ });
818
+ };
819
+
820
+ transport = (async () => {
821
+ while (exitCode === null && !consumerStopped && !opts.signal?.aborted) {
822
+ let buf = "";
823
+ let handle: SandboxBackgroundProcess | null = null;
824
+ const onStdout = (data: string): void => {
825
+ buf += data;
826
+ let nl: number;
827
+ while ((nl = buf.indexOf("\n")) >= 0) {
828
+ consumeLine(buf.slice(0, nl));
829
+ buf = buf.slice(nl + 1);
830
+ if (exitCode !== null) { void handle?.kill().catch(() => { /* racing exit */ }); return; }
831
+ }
832
+ };
833
+ try {
834
+ handle = await this.sandbox.commands.runBackground!(
835
+ `tail -c +${offset + 1} -f ${shellQuote(outPath)}`,
836
+ { timeoutMs: 0, onStdout });
837
+ } catch { /* attach failed at the wire — durable catch-up below */ }
838
+ if (handle) {
839
+ if (exitCode !== null) void handle.kill().catch(() => { /* done */ });
840
+ currentTail = handle;
841
+ await handle.wait().catch(() => undefined);
842
+ currentTail = null;
843
+ }
844
+ if (exitCode !== null || consumerStopped || opts.signal?.aborted) break;
845
+ // The tail stream died without delivering the exit line. Catch up
846
+ // over the FILE transport (envd HTTP — immune to the command-
847
+ // stream fault): this is also what recovers an already-finished
848
+ // turn's result when every stream is dead.
849
+ if (durableRead) {
850
+ try {
851
+ const bytes = Buffer.from(await durableRead(outPath), "utf8");
852
+ let rest = bytes.subarray(offset).toString("utf8");
853
+ let nl: number;
854
+ while (exitCode === null && (nl = rest.indexOf("\n")) >= 0) {
855
+ consumeLine(rest.slice(0, nl));
856
+ rest = rest.slice(nl + 1);
857
+ }
858
+ } catch { /* file plane briefly unreachable — retried below */ }
859
+ }
860
+ if (exitCode !== null) break;
861
+ // Runner still alive? A FRESH exec answers; unknown (wire fault)
862
+ // keeps retrying — the executor's evidence machinery bounds a
863
+ // truly dead sandbox, not this loop.
864
+ let alive: boolean | null = null;
865
+ try {
866
+ const probe = await this.sandbox.commands.run(
867
+ `kill -0 ${pid} 2>/dev/null`, { timeoutMs: 10_000 });
868
+ alive = probe.exitCode === 0;
869
+ } catch { alive = null; }
870
+ if (alive === false) {
871
+ // Dead with no exit line even in the durable file: abnormal
872
+ // death — surfaces as an error, never a clean done.
873
+ exitCode = JSONL_GUARD_NO_SENTINEL_EXIT;
874
+ break;
875
+ }
876
+ console.warn(`[cli-agent] ${this.spec.kind} turn stream lost with the runner alive — re-tailing ${outPath} from byte ${offset}`);
877
+ await new Promise((r) => setTimeout(r, TAIL_REATTACH_DELAY_MS));
878
+ }
879
+ if (exitCode !== null && exitCode !== 0 && durableRead) {
880
+ try { exitStderr = (await durableRead(errPath)).slice(-2000); }
881
+ catch { /* best-effort */ }
882
+ }
883
+ })().finally(() => lines.close());
682
884
  } else {
683
- runPromise = this.sandbox.commands.run(guardedCmd, runOpts).then(settle, fail);
885
+ // ── Single-exec streaming transport (no runBackground/files.read) ──
886
+ let buf = "";
887
+ const onStdout = (data: string) => {
888
+ buf += data;
889
+ let nl: number;
890
+ while ((nl = buf.indexOf("\n")) >= 0) {
891
+ const line = buf.slice(0, nl).trim();
892
+ buf = buf.slice(nl + 1);
893
+ if (line) lines.push(line);
894
+ }
895
+ };
896
+ const runOpts = {
897
+ ...(this.options.cwd ? { cwd: this.options.cwd } : {}),
898
+ onStdout,
899
+ timeoutMs: 0, // no provider deadline — see the doc comment above
900
+ };
901
+ transport = this.sandbox.commands.run(guardedCmd, runOpts).then(
902
+ (res) => {
903
+ const tail = buf.trim();
904
+ if (tail) lines.push(tail);
905
+ exitCode = res.exitCode;
906
+ exitStderr = (res.stderr ?? "").slice(-2000);
907
+ },
908
+ (err) => { transportError = err; },
909
+ ).finally(() => lines.close());
684
910
  }
685
- // An early generator exit stops awaiting runPromise — keep its rejection
911
+ // An early generator exit stops awaiting the transport — keep it
686
912
  // observed so an aborted turn can never surface an unhandled rejection.
687
- runPromise.catch(() => { /* observed via `await runPromise` when consumed */ });
913
+ void transport.catch(() => { /* observed via `await transport` when consumed */ });
688
914
 
689
- const reap = () => { if (!exited) void kill?.().catch(() => { /* already gone */ }); };
690
915
  if (opts.signal) {
691
916
  if (opts.signal.aborted) reap();
692
917
  else opts.signal.addEventListener("abort", reap, { once: true });
@@ -707,26 +932,38 @@ export class CliAgentRunner implements ModelExecutionContract {
707
932
  }
708
933
  }
709
934
 
710
- const res = await runPromise;
711
- if (res.exitCode !== 0 && !sawError) {
712
- const tail = (res.stderr ?? "").slice(-2000);
713
- yield { type: "error", text: `${this.spec.kind} exited with code ${res.exitCode}${tail ? `: ${tail}` : ""}`, timestamp: now() };
935
+ await transport;
936
+ if (transportError) throw transportError;
937
+ if (exitCode === null) {
938
+ // Aborted/reaped before an exit line existed — honest terminal,
939
+ // never a hang and never a clean done.
940
+ if (!sawError) {
941
+ yield { type: "error", text: `${this.spec.kind} aborted — the in-sandbox runner was terminated`, timestamp: now() };
942
+ }
943
+ return;
944
+ }
945
+ if (exitCode !== 0 && !sawError) {
946
+ const detail = exitCode === JSONL_GUARD_NO_SENTINEL_EXIT
947
+ ? " (the runner died without reporting an exit code)" : "";
948
+ yield { type: "error", text: `${this.spec.kind} exited with code ${exitCode}${detail}${exitStderr ? `: ${exitStderr}` : ""}`, timestamp: now() };
714
949
  return;
715
950
  }
716
951
  if (!sawError) yield { type: "done", sessionId: sessionId ?? "", timestamp: now() };
717
952
  } finally {
718
953
  // Runs on completion AND on early generator exit (the caller broke
719
954
  // out of its for-await: abort, turn closed under the executor,
720
- // supersede). `reap` is a no-op once the process has exited.
955
+ // supersede). Reap only while the runner hasn't reported an exit.
721
956
  opts.signal?.removeEventListener("abort", reap);
722
- reap();
723
- // Opportunistic prompt-file + guard-script cleanup: paths are never
724
- // reused (see uniquePromptPath), so this is hygiene, not correctness —
725
- // fire and forget, and a dead sandbox / failed rm is fine. The shell
726
- // holds an open fd on the redirect, so unlinking under a still-exiting
727
- // CLI is harmless.
957
+ consumerStopped = true;
958
+ if (exitCode === null) reap();
959
+ // Opportunistic prompt-file + guard-script + transcript cleanup:
960
+ // paths are never reused (see uniquePromptPath), so this is hygiene,
961
+ // not correctness — fire and forget, and a dead sandbox / failed rm
962
+ // is fine. The shell holds an open fd on the redirect, so unlinking
963
+ // under a still-exiting CLI is harmless.
728
964
  void this.sandbox.commands.run(
729
- `rm -f ${shellQuote(promptPath)} ${shellQuote(guardPath)}`, { timeoutMs: 10_000 })
965
+ `rm -f ${shellQuote(promptPath)} ${shellQuote(guardPath)} ${shellQuote(`${promptPath}.out`)} ${shellQuote(`${promptPath}.err`)} ${shellQuote(`${promptPath}.pid`)}`,
966
+ { timeoutMs: 10_000 })
730
967
  .catch(() => { /* best-effort */ });
731
968
  }
732
969
  } catch (err) {
@@ -95,14 +95,20 @@ export const codexSpec: CliAgentSpec = {
95
95
  // backstop for a caller that bypasses that gate. The value comes from
96
96
  // the closed CliReasoningEffort set, so it is shell-safe unquoted.
97
97
  ...(effort ? ["-c", `model_reasoning_effort=${effort === "max" ? "xhigh" : effort}`] : []),
98
- ...(cwd ? ["-C", shellQuote(cwd)] : []),
99
98
  ].join(" ");
99
+ // Working directory via a shell `cd` — the idiom EVERY other runtime uses
100
+ // (claude-code/cursor/droid/opencode) — NOT codex's `-C` flag. `-C` is
101
+ // accepted by `codex exec` but REJECTED by `codex exec resume`
102
+ // ("unexpected argument '-C'"), so a fresh turn worked and every follow-up
103
+ // died. `cd` sets the cwd identically for both paths; promptPath is
104
+ // absolute (/tmp/…), so the `< prompt` redirect survives the cd.
105
+ const cd = cwd ? `cd ${shellQuote(cwd)} && ` : "";
100
106
  // Fresh turn: `codex exec <flags> - < prompt`. Continue a thread:
101
107
  // `codex exec resume <id> <flags> - < prompt`. (`-` = read prompt from stdin.)
102
108
  const exec = sessionId
103
109
  ? `codex exec resume ${shellQuote(sessionId)} ${flags}`
104
110
  : `codex exec ${flags}`;
105
- return `${exec} - < ${shellQuote(promptPath)}`;
111
+ return `${cd}${exec} - < ${shellQuote(promptPath)}`;
106
112
  },
107
113
  extractSessionId: (p) =>
108
114
  p.type === "thread.started" && typeof p.thread_id === "string" ? p.thread_id : undefined,
@@ -237,11 +237,15 @@ function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
237
237
  return { resumeHandle: sb.sandboxId };
238
238
  },
239
239
  // The provider kill-clock seam: e2b's `setTimeout` REPLACES the deadline
240
- // (extend or reduce) relative to now. The server's session lifecycle
241
- // keeps this behind its own suspend clock so the deferred pause always
242
- // wins the race against the create-time timeout.
240
+ // (extend or reduce) relative to now, in MILLISECONDS. The server's
241
+ // session lifecycle uses it two ways: a FAR-HORIZON push while the
242
+ // session is active (the kill-clock is an orphan backstop, never
243
+ // load-bearing during a live turn) and a short re-arm when the deferred
244
+ // pause is about to park the VM. Clamped to the plan cap exactly like
245
+ // the create timeout — an over-cap push would 400 and leave the OLD
246
+ // (possibly short) deadline standing.
243
247
  async extendLifetime(ms) {
244
- await sb.setTimeout(ms);
248
+ await sb.setTimeout(Math.min(ms, e2bMaxSandboxMs()));
245
249
  },
246
250
  // Push a freshly-resolved egress policy onto the live sandbox via E2B's
247
251
  // native `updateNetwork` — the E2B analogue of Vercel's `update({