@tanstack/ai-sandbox 0.1.0

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.
Files changed (109) hide show
  1. package/README.md +182 -0
  2. package/dist/esm/agents-file.d.ts +36 -0
  3. package/dist/esm/agents-file.js +44 -0
  4. package/dist/esm/agents-file.js.map +1 -0
  5. package/dist/esm/approvals.d.ts +38 -0
  6. package/dist/esm/approvals.js +36 -0
  7. package/dist/esm/approvals.js.map +1 -0
  8. package/dist/esm/bootstrap.d.ts +17 -0
  9. package/dist/esm/bootstrap.js +124 -0
  10. package/dist/esm/bootstrap.js.map +1 -0
  11. package/dist/esm/bridge-events.d.ts +21 -0
  12. package/dist/esm/bridge-events.js +76 -0
  13. package/dist/esm/bridge-events.js.map +1 -0
  14. package/dist/esm/capabilities.d.ts +26 -0
  15. package/dist/esm/capabilities.js +29 -0
  16. package/dist/esm/capabilities.js.map +1 -0
  17. package/dist/esm/contracts.d.ts +211 -0
  18. package/dist/esm/errors.d.ts +16 -0
  19. package/dist/esm/errors.js +25 -0
  20. package/dist/esm/errors.js.map +1 -0
  21. package/dist/esm/git-exec.d.ts +2 -0
  22. package/dist/esm/git-exec.js +68 -0
  23. package/dist/esm/git-exec.js.map +1 -0
  24. package/dist/esm/harness-cwd.d.ts +2 -0
  25. package/dist/esm/harness-cwd.js +24 -0
  26. package/dist/esm/harness-cwd.js.map +1 -0
  27. package/dist/esm/index.d.ts +39 -0
  28. package/dist/esm/index.js +103 -0
  29. package/dist/esm/index.js.map +1 -0
  30. package/dist/esm/key.d.ts +20 -0
  31. package/dist/esm/key.js +41 -0
  32. package/dist/esm/key.js.map +1 -0
  33. package/dist/esm/middleware.d.ts +5 -0
  34. package/dist/esm/middleware.js +140 -0
  35. package/dist/esm/middleware.js.map +1 -0
  36. package/dist/esm/ngrok.d.ts +16 -0
  37. package/dist/esm/ngrok.js +54 -0
  38. package/dist/esm/ngrok.js.map +1 -0
  39. package/dist/esm/policy.d.ts +47 -0
  40. package/dist/esm/policy.js +44 -0
  41. package/dist/esm/policy.js.map +1 -0
  42. package/dist/esm/projection.d.ts +31 -0
  43. package/dist/esm/projection.js +9 -0
  44. package/dist/esm/projection.js.map +1 -0
  45. package/dist/esm/remote-tools.d.ts +48 -0
  46. package/dist/esm/remote-tools.js +76 -0
  47. package/dist/esm/remote-tools.js.map +1 -0
  48. package/dist/esm/run-log.d.ts +81 -0
  49. package/dist/esm/run-log.js +107 -0
  50. package/dist/esm/run-log.js.map +1 -0
  51. package/dist/esm/run.d.ts +58 -0
  52. package/dist/esm/run.js +89 -0
  53. package/dist/esm/run.js.map +1 -0
  54. package/dist/esm/runner.d.ts +21 -0
  55. package/dist/esm/runner.js +54 -0
  56. package/dist/esm/runner.js.map +1 -0
  57. package/dist/esm/sandbox.d.ts +79 -0
  58. package/dist/esm/sandbox.js +125 -0
  59. package/dist/esm/sandbox.js.map +1 -0
  60. package/dist/esm/secrets.d.ts +37 -0
  61. package/dist/esm/secrets.js +59 -0
  62. package/dist/esm/secrets.js.map +1 -0
  63. package/dist/esm/setup-plan.d.ts +13 -0
  64. package/dist/esm/setup-plan.js +16 -0
  65. package/dist/esm/setup-plan.js.map +1 -0
  66. package/dist/esm/shell.d.ts +45 -0
  67. package/dist/esm/shell.js +164 -0
  68. package/dist/esm/shell.js.map +1 -0
  69. package/dist/esm/store.d.ts +53 -0
  70. package/dist/esm/store.js +34 -0
  71. package/dist/esm/store.js.map +1 -0
  72. package/dist/esm/tool-bridge.d.ts +130 -0
  73. package/dist/esm/tool-bridge.js +197 -0
  74. package/dist/esm/tool-bridge.js.map +1 -0
  75. package/dist/esm/watch.d.ts +36 -0
  76. package/dist/esm/watch.js +144 -0
  77. package/dist/esm/watch.js.map +1 -0
  78. package/dist/esm/workspace.d.ts +128 -0
  79. package/dist/esm/workspace.js +42 -0
  80. package/dist/esm/workspace.js.map +1 -0
  81. package/package.json +72 -0
  82. package/skills/ai-sandbox/SKILL.md +366 -0
  83. package/src/agents-file.ts +101 -0
  84. package/src/approvals.ts +96 -0
  85. package/src/bootstrap.ts +196 -0
  86. package/src/bridge-events.ts +112 -0
  87. package/src/capabilities.ts +47 -0
  88. package/src/contracts.ts +236 -0
  89. package/src/errors.ts +31 -0
  90. package/src/git-exec.ts +114 -0
  91. package/src/harness-cwd.ts +38 -0
  92. package/src/index.ts +222 -0
  93. package/src/key.ts +70 -0
  94. package/src/middleware.ts +233 -0
  95. package/src/ngrok.ts +85 -0
  96. package/src/policy.ts +111 -0
  97. package/src/projection.ts +46 -0
  98. package/src/remote-tools.ts +180 -0
  99. package/src/run-log.ts +224 -0
  100. package/src/run.ts +167 -0
  101. package/src/runner.ts +99 -0
  102. package/src/sandbox.ts +259 -0
  103. package/src/secrets.ts +101 -0
  104. package/src/setup-plan.ts +25 -0
  105. package/src/shell.ts +288 -0
  106. package/src/store.ts +83 -0
  107. package/src/tool-bridge.ts +399 -0
  108. package/src/watch.ts +256 -0
  109. package/src/workspace.ts +151 -0
@@ -0,0 +1,81 @@
1
+ import { StreamChunk } from '@tanstack/ai';
2
+ /** A terminal run status: no further events will be appended. */
3
+ export type TerminalRunStatus = 'done' | 'error' | 'aborted';
4
+ /** Lifecycle status of a run. `done`/`error`/`aborted` are terminal. */
5
+ export type RunStatus = 'running' | TerminalRunStatus;
6
+ /** Whether a run status is terminal (no further events will be appended). */
7
+ export declare function isTerminalRunStatus(status: RunStatus): boolean;
8
+ export interface RunError {
9
+ message: string;
10
+ code?: string;
11
+ }
12
+ /** Durable bookkeeping for a single run. */
13
+ export interface RunRecord {
14
+ runId: string;
15
+ threadId?: string;
16
+ status: RunStatus;
17
+ /** Seq of the last appended event, or `-1` when no events yet. */
18
+ lastSeq: number;
19
+ error?: RunError;
20
+ createdAt: number;
21
+ updatedAt: number;
22
+ }
23
+ /** One persisted event: a chunk plus its monotonic, gap-free sequence number. */
24
+ export interface RunEvent {
25
+ seq: number;
26
+ chunk: StreamChunk;
27
+ }
28
+ export interface RunEventLogReadOptions {
29
+ /**
30
+ * Exclusive cursor: only events with `seq > fromSeq` are yielded. Pass the
31
+ * client's last-seen `seq` to resume; omit (or `-1`) to replay from the start.
32
+ */
33
+ fromSeq?: number;
34
+ /** Stop tailing when this fires (e.g. the client disconnected). */
35
+ signal?: AbortSignal;
36
+ }
37
+ /**
38
+ * Append-only, `seq`-indexed log of a run's stream, with resumable reads.
39
+ *
40
+ * Contract:
41
+ * - `append` assigns the next `seq` (0, 1, 2, …) and returns it.
42
+ * - `read` yields the backlog after `fromSeq` in order, then live-tails new
43
+ * events, and RETURNS once the run is terminal and the cursor has caught up.
44
+ * - All methods reject for an unknown `runId` except `get`, which resolves null.
45
+ */
46
+ export interface RunEventLog {
47
+ /** Idempotently create (or return) the run record. */
48
+ open: (input: {
49
+ runId: string;
50
+ threadId?: string;
51
+ }) => Promise<RunRecord>;
52
+ /** Append one chunk; resolves with its assigned `seq`. */
53
+ append: (runId: string, chunk: StreamChunk) => Promise<number>;
54
+ /** Move the run to a terminal status. Idempotent for the same status. */
55
+ finish: (runId: string, status: TerminalRunStatus, error?: RunError) => Promise<void>;
56
+ /** Current record, or null if the run is unknown. */
57
+ get: (runId: string) => Promise<RunRecord | null>;
58
+ /** Replay-then-tail events with `seq > fromSeq` until the run is terminal. */
59
+ read: (runId: string, options?: RunEventLogReadOptions) => AsyncIterable<RunEvent>;
60
+ }
61
+ /**
62
+ * Single-process {@link RunEventLog}. Backs `read`'s live-tail with an internal
63
+ * waiter set: `append`/`finish` wake every blocked reader. Suitable for a
64
+ * long-running Node host, tests, and as the reference implementation a durable
65
+ * backend mirrors.
66
+ */
67
+ export declare class InMemoryRunEventLog implements RunEventLog {
68
+ private readonly runs;
69
+ private now;
70
+ private require;
71
+ private wake;
72
+ open(input: {
73
+ runId: string;
74
+ threadId?: string;
75
+ }): Promise<RunRecord>;
76
+ append(runId: string, chunk: StreamChunk): Promise<number>;
77
+ finish(runId: string, status: TerminalRunStatus, error?: RunError): Promise<void>;
78
+ get(runId: string): Promise<RunRecord | null>;
79
+ read(runId: string, options?: RunEventLogReadOptions): AsyncIterable<RunEvent>;
80
+ private waitForChange;
81
+ }
@@ -0,0 +1,107 @@
1
+ const TERMINAL = /* @__PURE__ */ new Set([
2
+ "done",
3
+ "error",
4
+ "aborted"
5
+ ]);
6
+ function isTerminalRunStatus(status) {
7
+ return TERMINAL.has(status);
8
+ }
9
+ class InMemoryRunEventLog {
10
+ runs = /* @__PURE__ */ new Map();
11
+ now() {
12
+ return Date.now();
13
+ }
14
+ require(runId) {
15
+ const state = this.runs.get(runId);
16
+ if (!state) throw new Error(`run-log: unknown runId "${runId}"`);
17
+ return state;
18
+ }
19
+ wake(state) {
20
+ const waiters = [...state.waiters];
21
+ state.waiters.clear();
22
+ for (const resolve of waiters) resolve();
23
+ }
24
+ // Mutators return a Promise without `async` so contract violations REJECT
25
+ // (rather than throwing synchronously from a Promise-typed method — a
26
+ // `.catch()` footgun) without an `await`-less async body.
27
+ open(input) {
28
+ const existing = this.runs.get(input.runId);
29
+ if (existing) return Promise.resolve({ ...existing.record });
30
+ const now = this.now();
31
+ const record = {
32
+ runId: input.runId,
33
+ ...input.threadId !== void 0 ? { threadId: input.threadId } : {},
34
+ status: "running",
35
+ lastSeq: -1,
36
+ createdAt: now,
37
+ updatedAt: now
38
+ };
39
+ this.runs.set(input.runId, { record, chunks: [], waiters: /* @__PURE__ */ new Set() });
40
+ return Promise.resolve({ ...record });
41
+ }
42
+ append(runId, chunk) {
43
+ const state = this.runs.get(runId);
44
+ if (!state) {
45
+ return Promise.reject(new Error(`run-log: unknown runId "${runId}"`));
46
+ }
47
+ if (isTerminalRunStatus(state.record.status)) {
48
+ return Promise.reject(
49
+ new Error(
50
+ `run-log: cannot append to terminal run "${runId}" (status=${state.record.status})`
51
+ )
52
+ );
53
+ }
54
+ const seq = state.record.lastSeq + 1;
55
+ state.chunks.push(chunk);
56
+ state.record.lastSeq = seq;
57
+ state.record.updatedAt = this.now();
58
+ this.wake(state);
59
+ return Promise.resolve(seq);
60
+ }
61
+ finish(runId, status, error) {
62
+ const state = this.runs.get(runId);
63
+ if (!state) {
64
+ return Promise.reject(new Error(`run-log: unknown runId "${runId}"`));
65
+ }
66
+ if (isTerminalRunStatus(state.record.status)) return Promise.resolve();
67
+ state.record.status = status;
68
+ if (error !== void 0) state.record.error = error;
69
+ state.record.updatedAt = this.now();
70
+ this.wake(state);
71
+ return Promise.resolve();
72
+ }
73
+ get(runId) {
74
+ const state = this.runs.get(runId);
75
+ return Promise.resolve(state ? { ...state.record } : null);
76
+ }
77
+ async *read(runId, options) {
78
+ const state = this.require(runId);
79
+ const signal = options?.signal;
80
+ let cursor = options?.fromSeq ?? -1;
81
+ while (!signal?.aborted) {
82
+ while (cursor < state.record.lastSeq) {
83
+ cursor += 1;
84
+ const chunk = state.chunks[cursor];
85
+ if (chunk !== void 0) yield { seq: cursor, chunk };
86
+ }
87
+ if (isTerminalRunStatus(state.record.status)) return;
88
+ await this.waitForChange(state, signal);
89
+ }
90
+ }
91
+ waitForChange(state, signal) {
92
+ return new Promise((resolve) => {
93
+ const wake = () => {
94
+ state.waiters.delete(wake);
95
+ if (signal) signal.removeEventListener("abort", wake);
96
+ resolve();
97
+ };
98
+ state.waiters.add(wake);
99
+ if (signal) signal.addEventListener("abort", wake, { once: true });
100
+ });
101
+ }
102
+ }
103
+ export {
104
+ InMemoryRunEventLog,
105
+ isTerminalRunStatus
106
+ };
107
+ //# sourceMappingURL=run-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-log.js","sources":["../../src/run-log.ts"],"sourcesContent":["/**\n * Resumable run event-log — the primitive that lets a trigger (e.g. a\n * Cloudflare Worker) start an agent run and return immediately while a durable\n * orchestrator (e.g. a Durable Object) drives the run and persists every\n * emitted {@link StreamChunk} under a monotonic `seq`.\n *\n * Clients tail the log from a cursor (`fromSeq`), so a dropped connection, a new\n * browser tab, or an orchestrator that hibernated between chunks all reconnect\n * cleanly: replay everything after the client's last-seen `seq`, then live-tail\n * until the run reaches a terminal status. The *run* never depends on any single\n * connection staying open — that is what makes the serverless/edge model work.\n *\n * This module is transport- and storage-agnostic. {@link InMemoryRunEventLog} is\n * the default (single-process / tests); a durable backend (DO storage, KV, SQL)\n * implements the same {@link RunEventLog} interface — see the Cloudflare example.\n */\nimport type { StreamChunk } from '@tanstack/ai'\n\n/** A terminal run status: no further events will be appended. */\nexport type TerminalRunStatus = 'done' | 'error' | 'aborted'\n\n/** Lifecycle status of a run. `done`/`error`/`aborted` are terminal. */\nexport type RunStatus = 'running' | TerminalRunStatus\n\nconst TERMINAL: ReadonlySet<RunStatus> = new Set<RunStatus>([\n 'done',\n 'error',\n 'aborted',\n])\n\n/** Whether a run status is terminal (no further events will be appended). */\nexport function isTerminalRunStatus(status: RunStatus): boolean {\n return TERMINAL.has(status)\n}\n\nexport interface RunError {\n message: string\n code?: string\n}\n\n/** Durable bookkeeping for a single run. */\nexport interface RunRecord {\n runId: string\n threadId?: string\n status: RunStatus\n /** Seq of the last appended event, or `-1` when no events yet. */\n lastSeq: number\n error?: RunError\n createdAt: number\n updatedAt: number\n}\n\n/** One persisted event: a chunk plus its monotonic, gap-free sequence number. */\nexport interface RunEvent {\n seq: number\n chunk: StreamChunk\n}\n\nexport interface RunEventLogReadOptions {\n /**\n * Exclusive cursor: only events with `seq > fromSeq` are yielded. Pass the\n * client's last-seen `seq` to resume; omit (or `-1`) to replay from the start.\n */\n fromSeq?: number\n /** Stop tailing when this fires (e.g. the client disconnected). */\n signal?: AbortSignal\n}\n\n/**\n * Append-only, `seq`-indexed log of a run's stream, with resumable reads.\n *\n * Contract:\n * - `append` assigns the next `seq` (0, 1, 2, …) and returns it.\n * - `read` yields the backlog after `fromSeq` in order, then live-tails new\n * events, and RETURNS once the run is terminal and the cursor has caught up.\n * - All methods reject for an unknown `runId` except `get`, which resolves null.\n */\nexport interface RunEventLog {\n /** Idempotently create (or return) the run record. */\n open: (input: { runId: string; threadId?: string }) => Promise<RunRecord>\n /** Append one chunk; resolves with its assigned `seq`. */\n append: (runId: string, chunk: StreamChunk) => Promise<number>\n /** Move the run to a terminal status. Idempotent for the same status. */\n finish: (\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ) => Promise<void>\n /** Current record, or null if the run is unknown. */\n get: (runId: string) => Promise<RunRecord | null>\n /** Replay-then-tail events with `seq > fromSeq` until the run is terminal. */\n read: (\n runId: string,\n options?: RunEventLogReadOptions,\n ) => AsyncIterable<RunEvent>\n}\n\n/** Per-run state for the in-memory log. */\ninterface RunState {\n record: RunRecord\n chunks: Array<StreamChunk>\n /** Resolved (and cleared) whenever an event is appended or status changes. */\n waiters: Set<() => void>\n}\n\n/**\n * Single-process {@link RunEventLog}. Backs `read`'s live-tail with an internal\n * waiter set: `append`/`finish` wake every blocked reader. Suitable for a\n * long-running Node host, tests, and as the reference implementation a durable\n * backend mirrors.\n */\nexport class InMemoryRunEventLog implements RunEventLog {\n private readonly runs = new Map<string, RunState>()\n\n private now(): number {\n return Date.now()\n }\n\n private require(runId: string): RunState {\n const state = this.runs.get(runId)\n if (!state) throw new Error(`run-log: unknown runId \"${runId}\"`)\n return state\n }\n\n private wake(state: RunState): void {\n const waiters = [...state.waiters]\n state.waiters.clear()\n for (const resolve of waiters) resolve()\n }\n\n // Mutators return a Promise without `async` so contract violations REJECT\n // (rather than throwing synchronously from a Promise-typed method — a\n // `.catch()` footgun) without an `await`-less async body.\n open(input: { runId: string; threadId?: string }): Promise<RunRecord> {\n const existing = this.runs.get(input.runId)\n if (existing) return Promise.resolve({ ...existing.record })\n const now = this.now()\n const record: RunRecord = {\n runId: input.runId,\n ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),\n status: 'running',\n lastSeq: -1,\n createdAt: now,\n updatedAt: now,\n }\n this.runs.set(input.runId, { record, chunks: [], waiters: new Set() })\n return Promise.resolve({ ...record })\n }\n\n append(runId: string, chunk: StreamChunk): Promise<number> {\n const state = this.runs.get(runId)\n if (!state) {\n return Promise.reject(new Error(`run-log: unknown runId \"${runId}\"`))\n }\n if (isTerminalRunStatus(state.record.status)) {\n return Promise.reject(\n new Error(\n `run-log: cannot append to terminal run \"${runId}\" (status=${state.record.status})`,\n ),\n )\n }\n // Derive seq from the record's cursor (not `chunks.length`) so the gap-free\n // invariant holds the same way the durable backend computes it, even if the\n // backlog is ever trimmed/compacted.\n const seq = state.record.lastSeq + 1\n state.chunks.push(chunk)\n state.record.lastSeq = seq\n state.record.updatedAt = this.now()\n this.wake(state)\n return Promise.resolve(seq)\n }\n\n finish(\n runId: string,\n status: TerminalRunStatus,\n error?: RunError,\n ): Promise<void> {\n const state = this.runs.get(runId)\n if (!state) {\n return Promise.reject(new Error(`run-log: unknown runId \"${runId}\"`))\n }\n if (isTerminalRunStatus(state.record.status)) return Promise.resolve()\n state.record.status = status\n if (error !== undefined) state.record.error = error\n state.record.updatedAt = this.now()\n this.wake(state)\n return Promise.resolve()\n }\n\n get(runId: string): Promise<RunRecord | null> {\n const state = this.runs.get(runId)\n return Promise.resolve(state ? { ...state.record } : null)\n }\n\n async *read(\n runId: string,\n options?: RunEventLogReadOptions,\n ): AsyncIterable<RunEvent> {\n const state = this.require(runId)\n const signal = options?.signal\n let cursor = options?.fromSeq ?? -1\n while (!signal?.aborted) {\n while (cursor < state.record.lastSeq) {\n cursor += 1\n const chunk = state.chunks[cursor]\n if (chunk !== undefined) yield { seq: cursor, chunk }\n }\n if (isTerminalRunStatus(state.record.status)) return\n await this.waitForChange(state, signal)\n }\n }\n\n private waitForChange(state: RunState, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve) => {\n const wake = (): void => {\n state.waiters.delete(wake)\n if (signal) signal.removeEventListener('abort', wake)\n resolve()\n }\n state.waiters.add(wake)\n if (signal) signal.addEventListener('abort', wake, { once: true })\n })\n }\n}\n"],"names":[],"mappings":"AAwBA,MAAM,+BAAuC,IAAe;AAAA,EAC1D;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAGM,SAAS,oBAAoB,QAA4B;AAC9D,SAAO,SAAS,IAAI,MAAM;AAC5B;AA8EO,MAAM,oBAA2C;AAAA,EACrC,2BAAW,IAAA;AAAA,EAEpB,MAAc;AACpB,WAAO,KAAK,IAAA;AAAA,EACd;AAAA,EAEQ,QAAQ,OAAyB;AACvC,UAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;AACjC,QAAI,CAAC,MAAO,OAAM,IAAI,MAAM,2BAA2B,KAAK,GAAG;AAC/D,WAAO;AAAA,EACT;AAAA,EAEQ,KAAK,OAAuB;AAClC,UAAM,UAAU,CAAC,GAAG,MAAM,OAAO;AACjC,UAAM,QAAQ,MAAA;AACd,eAAW,WAAW,QAAS,SAAA;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA,EAKA,KAAK,OAAiE;AACpE,UAAM,WAAW,KAAK,KAAK,IAAI,MAAM,KAAK;AAC1C,QAAI,iBAAiB,QAAQ,QAAQ,EAAE,GAAG,SAAS,QAAQ;AAC3D,UAAM,MAAM,KAAK,IAAA;AACjB,UAAM,SAAoB;AAAA,MACxB,OAAO,MAAM;AAAA,MACb,GAAI,MAAM,aAAa,SAAY,EAAE,UAAU,MAAM,SAAA,IAAa,CAAA;AAAA,MAClE,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,WAAW;AAAA,MACX,WAAW;AAAA,IAAA;AAEb,SAAK,KAAK,IAAI,MAAM,OAAO,EAAE,QAAQ,QAAQ,CAAA,GAAI,SAAS,oBAAI,IAAA,GAAO;AACrE,WAAO,QAAQ,QAAQ,EAAE,GAAG,QAAQ;AAAA,EACtC;AAAA,EAEA,OAAO,OAAe,OAAqC;AACzD,UAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;AACjC,QAAI,CAAC,OAAO;AACV,aAAO,QAAQ,OAAO,IAAI,MAAM,2BAA2B,KAAK,GAAG,CAAC;AAAA,IACtE;AACA,QAAI,oBAAoB,MAAM,OAAO,MAAM,GAAG;AAC5C,aAAO,QAAQ;AAAA,QACb,IAAI;AAAA,UACF,2CAA2C,KAAK,aAAa,MAAM,OAAO,MAAM;AAAA,QAAA;AAAA,MAClF;AAAA,IAEJ;AAIA,UAAM,MAAM,MAAM,OAAO,UAAU;AACnC,UAAM,OAAO,KAAK,KAAK;AACvB,UAAM,OAAO,UAAU;AACvB,UAAM,OAAO,YAAY,KAAK,IAAA;AAC9B,SAAK,KAAK,KAAK;AACf,WAAO,QAAQ,QAAQ,GAAG;AAAA,EAC5B;AAAA,EAEA,OACE,OACA,QACA,OACe;AACf,UAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;AACjC,QAAI,CAAC,OAAO;AACV,aAAO,QAAQ,OAAO,IAAI,MAAM,2BAA2B,KAAK,GAAG,CAAC;AAAA,IACtE;AACA,QAAI,oBAAoB,MAAM,OAAO,MAAM,EAAG,QAAO,QAAQ,QAAA;AAC7D,UAAM,OAAO,SAAS;AACtB,QAAI,UAAU,OAAW,OAAM,OAAO,QAAQ;AAC9C,UAAM,OAAO,YAAY,KAAK,IAAA;AAC9B,SAAK,KAAK,KAAK;AACf,WAAO,QAAQ,QAAA;AAAA,EACjB;AAAA,EAEA,IAAI,OAA0C;AAC5C,UAAM,QAAQ,KAAK,KAAK,IAAI,KAAK;AACjC,WAAO,QAAQ,QAAQ,QAAQ,EAAE,GAAG,MAAM,OAAA,IAAW,IAAI;AAAA,EAC3D;AAAA,EAEA,OAAO,KACL,OACA,SACyB;AACzB,UAAM,QAAQ,KAAK,QAAQ,KAAK;AAChC,UAAM,SAAS,SAAS;AACxB,QAAI,SAAS,SAAS,WAAW;AACjC,WAAO,CAAC,QAAQ,SAAS;AACvB,aAAO,SAAS,MAAM,OAAO,SAAS;AACpC,kBAAU;AACV,cAAM,QAAQ,MAAM,OAAO,MAAM;AACjC,YAAI,UAAU,OAAW,OAAM,EAAE,KAAK,QAAQ,MAAA;AAAA,MAChD;AACA,UAAI,oBAAoB,MAAM,OAAO,MAAM,EAAG;AAC9C,YAAM,KAAK,cAAc,OAAO,MAAM;AAAA,IACxC;AAAA,EACF;AAAA,EAEQ,cAAc,OAAiB,QAAqC;AAC1E,WAAO,IAAI,QAAc,CAAC,YAAY;AACpC,YAAM,OAAO,MAAY;AACvB,cAAM,QAAQ,OAAO,IAAI;AACzB,YAAI,OAAQ,QAAO,oBAAoB,SAAS,IAAI;AACpD,gBAAA;AAAA,MACF;AACA,YAAM,QAAQ,IAAI,IAAI;AACtB,UAAI,eAAe,iBAAiB,SAAS,MAAM,EAAE,MAAM,MAAM;AAAA,IACnE,CAAC;AAAA,EACH;AACF;"}
@@ -0,0 +1,58 @@
1
+ import { StreamChunk } from '@tanstack/ai';
2
+ import { RunEvent, RunEventLog, RunRecord } from './run-log.js';
3
+ export interface PipeToRunLogOptions {
4
+ log: RunEventLog;
5
+ runId: string;
6
+ threadId?: string;
7
+ /** Abort consumption mid-stream; the run finishes as `aborted`. */
8
+ signal?: AbortSignal;
9
+ }
10
+ /**
11
+ * Open the run, append every chunk from `stream`, and finish with the right
12
+ * terminal status. Resolves with the final {@link RunRecord} and never rejects:
13
+ * a thrown stream error is surfaced as a `RUN_ERROR` event + the record's
14
+ * `error`, which is what tailing clients see.
15
+ *
16
+ * - normal completion → `finish('done')`
17
+ * - a `RUN_ERROR` chunk → append it, then `finish('error', { message, code })`
18
+ * - the stream throws → append a synthesized `RUN_ERROR`, then `finish('error')`
19
+ * - `signal` aborts mid-stream → stop consuming, `finish('aborted')`
20
+ */
21
+ export declare function pipeToRunLog(stream: AsyncIterable<StreamChunk>, opts: PipeToRunLogOptions): Promise<RunRecord>;
22
+ export interface RunControllerStartInput {
23
+ runId: string;
24
+ threadId?: string;
25
+ stream: AsyncIterable<StreamChunk>;
26
+ /** Abort consumption mid-stream; the run finishes as `aborted`. */
27
+ signal?: AbortSignal;
28
+ }
29
+ export interface RunHandle {
30
+ runId: string;
31
+ /** Resolves with the final record once the run reaches a terminal status. */
32
+ done: Promise<RunRecord>;
33
+ }
34
+ /**
35
+ * Thin orchestration helper over a {@link RunEventLog}: fire-and-track a run via
36
+ * {@link pipeToRunLog}, tail it from a cursor, and `drain()` all in-flight runs
37
+ * (e.g. inside a `ctx.waitUntil`). Holds no run state of its own beyond the set
38
+ * of currently in-flight `done` promises.
39
+ */
40
+ export declare class RunController {
41
+ private readonly log;
42
+ private readonly inFlight;
43
+ constructor(log: RunEventLog);
44
+ /**
45
+ * Kick off `pipeToRunLog` without awaiting it and return the `runId`
46
+ * immediately plus a `done` promise the orchestrator may await or detach.
47
+ */
48
+ start(input: RunControllerStartInput): RunHandle;
49
+ /** Resumable client tail — replay from `fromSeq`, then live-tail to terminal. */
50
+ attach(runId: string, opts?: {
51
+ fromSeq?: number;
52
+ signal?: AbortSignal;
53
+ }): AsyncIterable<RunEvent>;
54
+ /** Current run record, or null if the run is unknown. */
55
+ status(runId: string): Promise<RunRecord | null>;
56
+ /** Await every currently in-flight run's `done` promise. */
57
+ drain(): Promise<void>;
58
+ }
@@ -0,0 +1,89 @@
1
+ import { EventType } from "@tanstack/ai";
2
+ function isRunErrorChunk(chunk) {
3
+ return chunk.type === EventType.RUN_ERROR;
4
+ }
5
+ function runErrorFromChunk(chunk) {
6
+ return chunk.code !== void 0 ? { message: chunk.message, code: chunk.code } : { message: chunk.message };
7
+ }
8
+ function messageOf(error) {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+ function syntheticRunError(message) {
12
+ const chunk = {
13
+ type: EventType.RUN_ERROR,
14
+ message
15
+ };
16
+ return chunk;
17
+ }
18
+ async function pipeToRunLog(stream, opts) {
19
+ const { log, runId, threadId, signal } = opts;
20
+ await log.open(threadId !== void 0 ? { runId, threadId } : { runId });
21
+ if (signal?.aborted) {
22
+ await log.finish(runId, "aborted");
23
+ return reread(log, runId);
24
+ }
25
+ try {
26
+ for await (const chunk of stream) {
27
+ if (signal?.aborted) {
28
+ await log.finish(runId, "aborted");
29
+ return reread(log, runId);
30
+ }
31
+ await log.append(runId, chunk);
32
+ if (isRunErrorChunk(chunk)) {
33
+ await log.finish(runId, "error", runErrorFromChunk(chunk));
34
+ return reread(log, runId);
35
+ }
36
+ }
37
+ } catch (error) {
38
+ const message = messageOf(error);
39
+ await log.append(runId, syntheticRunError(message));
40
+ await log.finish(runId, "error", { message });
41
+ return reread(log, runId);
42
+ }
43
+ await log.finish(runId, "done");
44
+ return reread(log, runId);
45
+ }
46
+ async function reread(log, runId) {
47
+ const latest = await log.get(runId);
48
+ if (!latest) throw new Error(`run: record for "${runId}" vanished mid-run`);
49
+ return latest;
50
+ }
51
+ class RunController {
52
+ constructor(log) {
53
+ this.log = log;
54
+ }
55
+ log;
56
+ inFlight = /* @__PURE__ */ new Set();
57
+ /**
58
+ * Kick off `pipeToRunLog` without awaiting it and return the `runId`
59
+ * immediately plus a `done` promise the orchestrator may await or detach.
60
+ */
61
+ start(input) {
62
+ const done = pipeToRunLog(input.stream, {
63
+ log: this.log,
64
+ runId: input.runId,
65
+ ...input.threadId !== void 0 ? { threadId: input.threadId } : {},
66
+ ...input.signal !== void 0 ? { signal: input.signal } : {}
67
+ });
68
+ this.inFlight.add(done);
69
+ void done.finally(() => this.inFlight.delete(done));
70
+ return { runId: input.runId, done };
71
+ }
72
+ /** Resumable client tail — replay from `fromSeq`, then live-tail to terminal. */
73
+ attach(runId, opts) {
74
+ return this.log.read(runId, opts);
75
+ }
76
+ /** Current run record, or null if the run is unknown. */
77
+ status(runId) {
78
+ return this.log.get(runId);
79
+ }
80
+ /** Await every currently in-flight run's `done` promise. */
81
+ async drain() {
82
+ await Promise.all([...this.inFlight]);
83
+ }
84
+ }
85
+ export {
86
+ RunController,
87
+ pipeToRunLog
88
+ };
89
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.js","sources":["../../src/run.ts"],"sourcesContent":["/**\n * The \"run driver\" for the inverted/serverless sandbox model: pump a `chat()`\n * stream into a {@link RunEventLog} so a trigger can return immediately while a\n * durable orchestrator drives the run and clients tail from a cursor.\n *\n * The key inversion vs. a classic request/response handler: there is no caller\n * holding the stream open, so nothing to throw an error *back to*. The log is\n * the only channel — every chunk (including a terminal {@link EventType.RUN_ERROR})\n * is persisted under a `seq`, and a thrown stream error is recorded as a\n * synthesized `RUN_ERROR` event plus the record's `error` field. Tailing clients\n * therefore always observe failures; {@link pipeToRunLog} never rejects.\n */\nimport { EventType } from '@tanstack/ai'\nimport type { StreamChunk } from '@tanstack/ai'\nimport type { RunError, RunEvent, RunEventLog, RunRecord } from './run-log'\n\n/** Whether a chunk is the terminal error event the chat engine emits. */\nfunction isRunErrorChunk(\n chunk: StreamChunk,\n): chunk is StreamChunk & { message: string; code?: string } {\n return chunk.type === EventType.RUN_ERROR\n}\n\n/** Pull `{ message, code }` off a RUN_ERROR chunk for the run record. */\nfunction runErrorFromChunk(\n chunk: StreamChunk & { message: string; code?: string },\n): RunError {\n return chunk.code !== undefined\n ? { message: chunk.message, code: chunk.code }\n : { message: chunk.message }\n}\n\n/** Render an unknown thrown value as a stable error message. */\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\n/** Build the synthetic RUN_ERROR chunk appended when the stream throws. */\nfunction syntheticRunError(message: string): StreamChunk {\n const chunk: { type: EventType.RUN_ERROR; message: string } = {\n type: EventType.RUN_ERROR,\n message,\n }\n return chunk\n}\n\nexport interface PipeToRunLogOptions {\n log: RunEventLog\n runId: string\n threadId?: string\n /** Abort consumption mid-stream; the run finishes as `aborted`. */\n signal?: AbortSignal\n}\n\n/**\n * Open the run, append every chunk from `stream`, and finish with the right\n * terminal status. Resolves with the final {@link RunRecord} and never rejects:\n * a thrown stream error is surfaced as a `RUN_ERROR` event + the record's\n * `error`, which is what tailing clients see.\n *\n * - normal completion → `finish('done')`\n * - a `RUN_ERROR` chunk → append it, then `finish('error', { message, code })`\n * - the stream throws → append a synthesized `RUN_ERROR`, then `finish('error')`\n * - `signal` aborts mid-stream → stop consuming, `finish('aborted')`\n */\nexport async function pipeToRunLog(\n stream: AsyncIterable<StreamChunk>,\n opts: PipeToRunLogOptions,\n): Promise<RunRecord> {\n const { log, runId, threadId, signal } = opts\n await log.open(threadId !== undefined ? { runId, threadId } : { runId })\n if (signal?.aborted) {\n await log.finish(runId, 'aborted')\n return reread(log, runId)\n }\n\n try {\n for await (const chunk of stream) {\n if (signal?.aborted) {\n await log.finish(runId, 'aborted')\n return reread(log, runId)\n }\n await log.append(runId, chunk)\n if (isRunErrorChunk(chunk)) {\n await log.finish(runId, 'error', runErrorFromChunk(chunk))\n return reread(log, runId)\n }\n }\n } catch (error) {\n // Detached run: no caller to throw to. Record the failure in the log so\n // tailing clients observe it, then return — do NOT rethrow.\n const message = messageOf(error)\n await log.append(runId, syntheticRunError(message))\n await log.finish(runId, 'error', { message })\n return reread(log, runId)\n }\n\n await log.finish(runId, 'done')\n return reread(log, runId)\n}\n\n/** Re-read the now-terminal record; the run was just driven, so it must exist. */\nasync function reread(log: RunEventLog, runId: string): Promise<RunRecord> {\n const latest = await log.get(runId)\n if (!latest) throw new Error(`run: record for \"${runId}\" vanished mid-run`)\n return latest\n}\n\nexport interface RunControllerStartInput {\n runId: string\n threadId?: string\n stream: AsyncIterable<StreamChunk>\n /** Abort consumption mid-stream; the run finishes as `aborted`. */\n signal?: AbortSignal\n}\n\nexport interface RunHandle {\n runId: string\n /** Resolves with the final record once the run reaches a terminal status. */\n done: Promise<RunRecord>\n}\n\n/**\n * Thin orchestration helper over a {@link RunEventLog}: fire-and-track a run via\n * {@link pipeToRunLog}, tail it from a cursor, and `drain()` all in-flight runs\n * (e.g. inside a `ctx.waitUntil`). Holds no run state of its own beyond the set\n * of currently in-flight `done` promises.\n */\nexport class RunController {\n private readonly inFlight = new Set<Promise<RunRecord>>()\n\n constructor(private readonly log: RunEventLog) {}\n\n /**\n * Kick off `pipeToRunLog` without awaiting it and return the `runId`\n * immediately plus a `done` promise the orchestrator may await or detach.\n */\n start(input: RunControllerStartInput): RunHandle {\n const done = pipeToRunLog(input.stream, {\n log: this.log,\n runId: input.runId,\n ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),\n ...(input.signal !== undefined ? { signal: input.signal } : {}),\n })\n this.inFlight.add(done)\n void done.finally(() => this.inFlight.delete(done))\n return { runId: input.runId, done }\n }\n\n /** Resumable client tail — replay from `fromSeq`, then live-tail to terminal. */\n attach(\n runId: string,\n opts?: { fromSeq?: number; signal?: AbortSignal },\n ): AsyncIterable<RunEvent> {\n return this.log.read(runId, opts)\n }\n\n /** Current run record, or null if the run is unknown. */\n status(runId: string): Promise<RunRecord | null> {\n return this.log.get(runId)\n }\n\n /** Await every currently in-flight run's `done` promise. */\n async drain(): Promise<void> {\n await Promise.all([...this.inFlight])\n }\n}\n"],"names":[],"mappings":";AAiBA,SAAS,gBACP,OAC2D;AAC3D,SAAO,MAAM,SAAS,UAAU;AAClC;AAGA,SAAS,kBACP,OACU;AACV,SAAO,MAAM,SAAS,SAClB,EAAE,SAAS,MAAM,SAAS,MAAM,MAAM,KAAA,IACtC,EAAE,SAAS,MAAM,QAAA;AACvB;AAGA,SAAS,UAAU,OAAwB;AACzC,SAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D;AAGA,SAAS,kBAAkB,SAA8B;AACvD,QAAM,QAAwD;AAAA,IAC5D,MAAM,UAAU;AAAA,IAChB;AAAA,EAAA;AAEF,SAAO;AACT;AAqBA,eAAsB,aACpB,QACA,MACoB;AACpB,QAAM,EAAE,KAAK,OAAO,UAAU,WAAW;AACzC,QAAM,IAAI,KAAK,aAAa,SAAY,EAAE,OAAO,SAAA,IAAa,EAAE,OAAO;AACvE,MAAI,QAAQ,SAAS;AACnB,UAAM,IAAI,OAAO,OAAO,SAAS;AACjC,WAAO,OAAO,KAAK,KAAK;AAAA,EAC1B;AAEA,MAAI;AACF,qBAAiB,SAAS,QAAQ;AAChC,UAAI,QAAQ,SAAS;AACnB,cAAM,IAAI,OAAO,OAAO,SAAS;AACjC,eAAO,OAAO,KAAK,KAAK;AAAA,MAC1B;AACA,YAAM,IAAI,OAAO,OAAO,KAAK;AAC7B,UAAI,gBAAgB,KAAK,GAAG;AAC1B,cAAM,IAAI,OAAO,OAAO,SAAS,kBAAkB,KAAK,CAAC;AACzD,eAAO,OAAO,KAAK,KAAK;AAAA,MAC1B;AAAA,IACF;AAAA,EACF,SAAS,OAAO;AAGd,UAAM,UAAU,UAAU,KAAK;AAC/B,UAAM,IAAI,OAAO,OAAO,kBAAkB,OAAO,CAAC;AAClD,UAAM,IAAI,OAAO,OAAO,SAAS,EAAE,SAAS;AAC5C,WAAO,OAAO,KAAK,KAAK;AAAA,EAC1B;AAEA,QAAM,IAAI,OAAO,OAAO,MAAM;AAC9B,SAAO,OAAO,KAAK,KAAK;AAC1B;AAGA,eAAe,OAAO,KAAkB,OAAmC;AACzE,QAAM,SAAS,MAAM,IAAI,IAAI,KAAK;AAClC,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,oBAAoB,KAAK,oBAAoB;AAC1E,SAAO;AACT;AAsBO,MAAM,cAAc;AAAA,EAGzB,YAA6B,KAAkB;AAAlB,SAAA,MAAA;AAAA,EAAmB;AAAA,EAAnB;AAAA,EAFZ,+BAAe,IAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQhC,MAAM,OAA2C;AAC/C,UAAM,OAAO,aAAa,MAAM,QAAQ;AAAA,MACtC,KAAK,KAAK;AAAA,MACV,OAAO,MAAM;AAAA,MACb,GAAI,MAAM,aAAa,SAAY,EAAE,UAAU,MAAM,SAAA,IAAa,CAAA;AAAA,MAClE,GAAI,MAAM,WAAW,SAAY,EAAE,QAAQ,MAAM,WAAW,CAAA;AAAA,IAAC,CAC9D;AACD,SAAK,SAAS,IAAI,IAAI;AACtB,SAAK,KAAK,QAAQ,MAAM,KAAK,SAAS,OAAO,IAAI,CAAC;AAClD,WAAO,EAAE,OAAO,MAAM,OAAO,KAAA;AAAA,EAC/B;AAAA;AAAA,EAGA,OACE,OACA,MACyB;AACzB,WAAO,KAAK,IAAI,KAAK,OAAO,IAAI;AAAA,EAClC;AAAA;AAAA,EAGA,OAAO,OAA0C;AAC/C,WAAO,KAAK,IAAI,IAAI,KAAK;AAAA,EAC3B;AAAA;AAAA,EAGA,MAAM,QAAuB;AAC3B,UAAM,QAAQ,IAAI,CAAC,GAAG,KAAK,QAAQ,CAAC;AAAA,EACtC;AACF;"}
@@ -0,0 +1,21 @@
1
+ import { ProcessOptions, SandboxHandle } from './contracts.js';
2
+ export interface SpawnNdjsonOptions extends ProcessOptions {
3
+ /**
4
+ * Called for each raw stdout line that is non-empty but fails JSON parsing
5
+ * (e.g. a CLI banner). Defaults to ignoring it. Stderr is never parsed.
6
+ */
7
+ onNonJsonLine?: (line: string) => void;
8
+ /**
9
+ * Written to the process stdin (then stdin is closed) right after spawn —
10
+ * e.g. the agent prompt for `claude -p`. Avoids putting the prompt in argv.
11
+ */
12
+ input?: string;
13
+ }
14
+ /** Split a stream of arbitrary string chunks into complete lines. */
15
+ export declare function toLines(chunks: AsyncIterable<string>): AsyncIterable<string>;
16
+ /**
17
+ * Spawn `command` in the sandbox and yield each stdout line parsed as JSON.
18
+ * Resolves the spawn handle's exit via `wait()` after stdout closes; a non-zero
19
+ * exit with no events surfaced is the adapter's concern to detect.
20
+ */
21
+ export declare function spawnNdjson(handle: SandboxHandle, command: string, options?: SpawnNdjsonOptions): AsyncIterable<unknown>;
@@ -0,0 +1,54 @@
1
+ async function* toLines(chunks) {
2
+ let buffer = "";
3
+ for await (const chunk of chunks) {
4
+ buffer += chunk;
5
+ let newlineIndex = buffer.indexOf("\n");
6
+ while (newlineIndex !== -1) {
7
+ const line = buffer.slice(0, newlineIndex);
8
+ buffer = buffer.slice(newlineIndex + 1);
9
+ yield line;
10
+ newlineIndex = buffer.indexOf("\n");
11
+ }
12
+ }
13
+ if (buffer.length > 0) yield buffer;
14
+ }
15
+ async function* spawnNdjson(handle, command, options = {}) {
16
+ const { onNonJsonLine, input, ...processOptions } = options;
17
+ const proc = await handle.process.spawn(command, processOptions);
18
+ if (input !== void 0) {
19
+ await proc.stdin.write(input);
20
+ await proc.stdin.end();
21
+ }
22
+ const stderrChunks = [];
23
+ const stderrDrained = (async () => {
24
+ try {
25
+ for await (const chunk of proc.stderr) stderrChunks.push(chunk);
26
+ } catch {
27
+ }
28
+ })();
29
+ for await (const line of toLines(proc.stdout)) {
30
+ const trimmed = line.trim();
31
+ if (trimmed === "") continue;
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(trimmed);
35
+ } catch {
36
+ onNonJsonLine?.(trimmed);
37
+ continue;
38
+ }
39
+ yield parsed;
40
+ }
41
+ const exitCode = await proc.wait();
42
+ await stderrDrained;
43
+ if (exitCode !== 0) {
44
+ const stderr = stderrChunks.join("").trim();
45
+ throw new Error(
46
+ `Agent process exited with code ${exitCode}` + (stderr ? `: ${stderr.slice(0, 1e3)}` : "")
47
+ );
48
+ }
49
+ }
50
+ export {
51
+ spawnNdjson,
52
+ toLines
53
+ };
54
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.js","sources":["../../src/runner.ts"],"sourcesContent":["/**\n * The reusable \"run an agent CLI inside a sandbox and stream its events out\"\n * primitive. Harness adapters (claude-code, codex, …) spawn their CLI via the\n * uniform {@link SandboxHandle} and consume newline-delimited JSON from stdout,\n * which they then translate into AG-UI StreamChunks.\n *\n * This is intentionally transport-minimal: a stdout NDJSON pipe. Multi-client\n * reconnect / replay belongs to the persistence/EventLog layer, not here.\n */\nimport type { ProcessOptions, SandboxHandle } from './contracts'\n\nexport interface SpawnNdjsonOptions extends ProcessOptions {\n /**\n * Called for each raw stdout line that is non-empty but fails JSON parsing\n * (e.g. a CLI banner). Defaults to ignoring it. Stderr is never parsed.\n */\n onNonJsonLine?: (line: string) => void\n /**\n * Written to the process stdin (then stdin is closed) right after spawn —\n * e.g. the agent prompt for `claude -p`. Avoids putting the prompt in argv.\n */\n input?: string\n}\n\n/** Split a stream of arbitrary string chunks into complete lines. */\nexport async function* toLines(\n chunks: AsyncIterable<string>,\n): AsyncIterable<string> {\n let buffer = ''\n for await (const chunk of chunks) {\n buffer += chunk\n let newlineIndex = buffer.indexOf('\\n')\n while (newlineIndex !== -1) {\n const line = buffer.slice(0, newlineIndex)\n buffer = buffer.slice(newlineIndex + 1)\n yield line\n newlineIndex = buffer.indexOf('\\n')\n }\n }\n if (buffer.length > 0) yield buffer\n}\n\n/**\n * Spawn `command` in the sandbox and yield each stdout line parsed as JSON.\n * Resolves the spawn handle's exit via `wait()` after stdout closes; a non-zero\n * exit with no events surfaced is the adapter's concern to detect.\n */\nexport async function* spawnNdjson(\n handle: SandboxHandle,\n command: string,\n options: SpawnNdjsonOptions = {},\n): AsyncIterable<unknown> {\n const { onNonJsonLine, input, ...processOptions } = options\n const proc = await handle.process.spawn(command, processOptions)\n\n if (input !== undefined) {\n await proc.stdin.write(input)\n await proc.stdin.end()\n }\n\n // Drain stderr concurrently. A CLI that fails before producing stdout (a\n // broken install, an auth/permission refusal, …) prints to stderr and exits\n // non-zero; without this, stdout-only parsing yields nothing and the failure\n // vanishes. Capturing it lets us surface the cause below.\n const stderrChunks: Array<string> = []\n const stderrDrained = (async () => {\n try {\n for await (const chunk of proc.stderr) stderrChunks.push(chunk)\n } catch {\n // stderr stream torn down — use whatever was captured\n }\n })()\n\n for await (const line of toLines(proc.stdout)) {\n const trimmed = line.trim()\n if (trimmed === '') continue\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n } catch {\n onNonJsonLine?.(trimmed)\n continue\n }\n yield parsed\n }\n\n const exitCode = await proc.wait()\n await stderrDrained\n // A non-zero exit means the agent CLI itself failed. Throw so the adapter's\n // catch turns it into a RUN_ERROR the UI can show, instead of ending the\n // stream silently with no events.\n if (exitCode !== 0) {\n const stderr = stderrChunks.join('').trim()\n throw new Error(\n `Agent process exited with code ${exitCode}` +\n (stderr ? `: ${stderr.slice(0, 1000)}` : ''),\n )\n }\n}\n"],"names":[],"mappings":"AAyBA,gBAAuB,QACrB,QACuB;AACvB,MAAI,SAAS;AACb,mBAAiB,SAAS,QAAQ;AAChC,cAAU;AACV,QAAI,eAAe,OAAO,QAAQ,IAAI;AACtC,WAAO,iBAAiB,IAAI;AAC1B,YAAM,OAAO,OAAO,MAAM,GAAG,YAAY;AACzC,eAAS,OAAO,MAAM,eAAe,CAAC;AACtC,YAAM;AACN,qBAAe,OAAO,QAAQ,IAAI;AAAA,IACpC;AAAA,EACF;AACA,MAAI,OAAO,SAAS,EAAG,OAAM;AAC/B;AAOA,gBAAuB,YACrB,QACA,SACA,UAA8B,CAAA,GACN;AACxB,QAAM,EAAE,eAAe,OAAO,GAAG,mBAAmB;AACpD,QAAM,OAAO,MAAM,OAAO,QAAQ,MAAM,SAAS,cAAc;AAE/D,MAAI,UAAU,QAAW;AACvB,UAAM,KAAK,MAAM,MAAM,KAAK;AAC5B,UAAM,KAAK,MAAM,IAAA;AAAA,EACnB;AAMA,QAAM,eAA8B,CAAA;AACpC,QAAM,iBAAiB,YAAY;AACjC,QAAI;AACF,uBAAiB,SAAS,KAAK,OAAQ,cAAa,KAAK,KAAK;AAAA,IAChE,QAAQ;AAAA,IAER;AAAA,EACF,GAAA;AAEA,mBAAiB,QAAQ,QAAQ,KAAK,MAAM,GAAG;AAC7C,UAAM,UAAU,KAAK,KAAA;AACrB,QAAI,YAAY,GAAI;AACpB,QAAI;AACJ,QAAI;AACF,eAAS,KAAK,MAAM,OAAO;AAAA,IAC7B,QAAQ;AACN,sBAAgB,OAAO;AACvB;AAAA,IACF;AACA,UAAM;AAAA,EACR;AAEA,QAAM,WAAW,MAAM,KAAK,KAAA;AAC5B,QAAM;AAIN,MAAI,aAAa,GAAG;AAClB,UAAM,SAAS,aAAa,KAAK,EAAE,EAAE,KAAA;AACrC,UAAM,IAAI;AAAA,MACR,kCAAkC,QAAQ,MACvC,SAAS,KAAK,OAAO,MAAM,GAAG,GAAI,CAAC,KAAK;AAAA,IAAA;AAAA,EAE/C;AACF;"}
@@ -0,0 +1,79 @@
1
+ import { SandboxFileEvent } from '@tanstack/ai';
2
+ import { SandboxHandle, SandboxProvider } from './contracts.js';
3
+ import { LockStore, SandboxStore } from './store.js';
4
+ import { SandboxPolicy } from './policy.js';
5
+ import { WorkspaceDefinition } from './workspace.js';
6
+ /**
7
+ * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every
8
+ * create/change/delete during a chat run; lifecycle hooks fire server-side.
9
+ */
10
+ export interface SandboxHooks {
11
+ onFile?: (e: SandboxFileEvent) => void | Promise<void>;
12
+ onFileCreate?: (e: SandboxFileEvent) => void | Promise<void>;
13
+ onFileChange?: (e: SandboxFileEvent) => void | Promise<void>;
14
+ onFileDelete?: (e: SandboxFileEvent) => void | Promise<void>;
15
+ onReady?: (handle: SandboxHandle) => void | Promise<void>;
16
+ onError?: (err: unknown) => void | Promise<void>;
17
+ onDestroy?: () => void | Promise<void>;
18
+ }
19
+ export type ReuseStrategy = 'thread' | 'none';
20
+ export type SnapshotStrategy = 'after-setup' | 'after-run' | 'none';
21
+ export interface SandboxLifecycle {
22
+ /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */
23
+ reuse?: ReuseStrategy;
24
+ /** When to snapshot (provider-permitting). */
25
+ snapshot?: SnapshotStrategy;
26
+ /** Hint for how long a provider should keep the sandbox warm between runs. */
27
+ keepAlive?: string;
28
+ /** Destroy the sandbox after the run completes. */
29
+ destroyOnComplete?: boolean;
30
+ /**
31
+ * Maximum age of a sandbox record before it is discarded and re-created
32
+ * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),
33
+ * e.g. `'2h'` or `'30m'`.
34
+ */
35
+ snapshotMaxAge?: string;
36
+ }
37
+ export interface SandboxConfig {
38
+ id: string;
39
+ provider: SandboxProvider;
40
+ workspace?: WorkspaceDefinition;
41
+ policy?: SandboxPolicy;
42
+ lifecycle?: SandboxLifecycle;
43
+ /** Sandbox-scoped file/lifecycle hooks. */
44
+ hooks?: SandboxHooks;
45
+ /** Watch the workspace for file events (default true). Set false to disable. */
46
+ fileEvents?: boolean;
47
+ }
48
+ /** Context passed to `ensure()` by `withSandbox` (or advanced callers). */
49
+ export interface SandboxEnsureContext {
50
+ threadId: string;
51
+ runId: string;
52
+ /** Persistence seam; falls back to an in-memory store when absent. */
53
+ store?: SandboxStore;
54
+ /** Lock seam; falls back to an in-memory lock when absent. */
55
+ locks?: LockStore;
56
+ tenant?: {
57
+ userId?: string;
58
+ orgId?: string;
59
+ };
60
+ signal?: AbortSignal;
61
+ }
62
+ export interface SandboxDefinition {
63
+ readonly id: string;
64
+ readonly provider: SandboxProvider;
65
+ readonly workspace?: WorkspaceDefinition;
66
+ readonly policy?: SandboxPolicy;
67
+ readonly lifecycle?: SandboxLifecycle;
68
+ /** Sandbox-scoped file/lifecycle hooks. */
69
+ readonly hooks?: SandboxHooks;
70
+ /** Watch the workspace for file events (default true). Set false to disable. */
71
+ readonly fileEvents?: boolean;
72
+ /** Compound instance key for a given run context. */
73
+ key: (ctx: SandboxEnsureContext) => string;
74
+ /** Resume-or-create the sandbox for this thread/run. */
75
+ ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>;
76
+ /** Tear down the sandbox recorded for this key. */
77
+ destroy: (ctx: SandboxEnsureContext) => Promise<void>;
78
+ }
79
+ export declare function defineSandbox(config: SandboxConfig): SandboxDefinition;