ai-sdk-harness-sessions 0.0.0-stage → 0.2.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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +383 -2
  4. package/dist/definitions/agent.d.ts +31 -0
  5. package/dist/definitions/agent.js +1 -0
  6. package/dist/definitions/manager.d.ts +157 -0
  7. package/dist/definitions/manager.js +1 -0
  8. package/dist/definitions/sandboxes.d.ts +33 -0
  9. package/dist/definitions/sandboxes.js +1 -0
  10. package/dist/definitions/session.d.ts +83 -0
  11. package/dist/definitions/session.js +21 -0
  12. package/dist/definitions/store.d.ts +26 -0
  13. package/dist/definitions/store.js +1 -0
  14. package/dist/errors/session-capacity-error.d.ts +17 -0
  15. package/dist/errors/session-capacity-error.js +21 -0
  16. package/dist/errors/session-conflict-error.d.ts +20 -0
  17. package/dist/errors/session-conflict-error.js +24 -0
  18. package/dist/errors/session-not-found-error.d.ts +14 -0
  19. package/dist/errors/session-not-found-error.js +19 -0
  20. package/dist/index.d.ts +15 -0
  21. package/dist/index.js +8 -0
  22. package/dist/manager/live-session.d.ts +51 -0
  23. package/dist/manager/live-session.js +26 -0
  24. package/dist/manager/session-journal.d.ts +31 -0
  25. package/dist/manager/session-journal.js +57 -0
  26. package/dist/manager/session-manager.d.ts +18 -0
  27. package/dist/manager/session-manager.js +654 -0
  28. package/dist/sandboxes/sandbox-per-session.d.ts +20 -0
  29. package/dist/sandboxes/sandbox-per-session.js +29 -0
  30. package/dist/sandboxes/shared-sandbox.d.ts +50 -0
  31. package/dist/sandboxes/shared-sandbox.js +88 -0
  32. package/dist/store/memory-session-store.d.ts +8 -0
  33. package/dist/store/memory-session-store.js +34 -0
  34. package/dist/turns/approval-answers.d.ts +10 -0
  35. package/dist/turns/approval-answers.js +22 -0
  36. package/dist/turns/pending-input.d.ts +24 -0
  37. package/dist/turns/pending-input.js +169 -0
  38. package/dist/turns/turn-stream.d.ts +41 -0
  39. package/dist/turns/turn-stream.js +140 -0
  40. package/package.json +86 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # ai-sdk-harness-sessions
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 05ff36c: The `approve` option answers the approvals a turn asks for before anyone sees them — an approval policy, the grants of the session: a verdict is sent at once and the turn goes on in the same stream, the client seeing the answer; `undefined` leaves the approval to a person. When it answers part of a pause, `pendingInput.approvals` carries its answers in `decision`, and `continue()` sends them with the person's. An approval asked again right after it was answered, with the same input, is left to a person. `update(id, { metadata })` changes the metadata of a session, whatever its status. A session resumed without the turn it waited in tells the agent the answers to its questions in words.
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - d1ae211: First release: sessions for AI SDK harness agents. `createSessionManager()` runs the conversations of your users with any `HarnessAgent` — Claude Code, Codex or another runtime — through their whole lifecycle: `create()` opens a session in the background, `send()` streams a turn in reply to a message, one turn at a time, and the session keeps its conversation, readable with `get()` and `list()`. A turn that pauses on a tool approval, or on a tool your application runs, leaves the session `awaiting-input` until `continue()` brings the answers, which it can read from the message `useChat` sends back. A session idle for `idleTimeoutMs` is suspended, its harness session stopped with its state and its sandbox freed, and resumed where it was on the next message; `shutdown()` suspends every session when your server stops, so the next start resumes them, and a session a crash left live is marked `interrupted`. `subscribe()` sends every change, for a list that follows the sessions live.
14
+
15
+ Where each session runs is up to `sharedSandbox()`, which gives every session a view of one sandbox with a port of its own for its bridge (`fork()` of `ai-sdk-sandbox-sbx` and `ai-sdk-sandbox-cloud-run`), suspending the session idle the longest when every port is taken and stopping the sandbox once no session holds it with `stopWhenUnused`, or `sandboxPerSession()`, which gives each one a sandbox of its own. Sessions are kept in a `SessionStore`: `createMemorySessionStore()` by default, or yours on your database. `SessionNotFoundError`, `SessionConflictError` and `SessionCapacityError` say what went wrong, with an `isInstance()` that works across copies of the package.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fabien Pasquet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,384 @@
1
- # Temporary Holding Version
1
+ # ai-sdk-harness-sessions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/ai-sdk-harness-sessions"><img alt="npm" src="https://img.shields.io/npm/v/ai-sdk-harness-sessions" /></a>
5
+ <a href="https://github.com/fpasquet/ai-sdk-harness/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/fpasquet/ai-sdk-harness/actions/workflows/ci.yml/badge.svg" /></a>
6
+ <img alt="Node &gt;= 24" src="https://img.shields.io/badge/node-%3E%3D24-3c873a" />
7
+ <img alt="TypeScript strict" src="https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white" />
8
+ <a href="https://github.com/fpasquet/ai-sdk-harness/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
9
+ </p>
10
+
11
+ Sessions for [AI SDK harness agents](https://ai-sdk.dev/docs/ai-sdk-harnesses/harness-agent): the whole lifecycle of your users' conversations with Claude Code, Codex and the other runtimes `HarnessAgent` drives. One turn at a time, **suspended when idle and resumed on the next message**, kept across restarts of your server, waiting for an approval as long as it takes, and **several of them side by side in one sandbox**.
12
+
13
+ ![Two conversations of the Next.js example working at once in one Docker Sandbox, the others ready or suspended](https://raw.githubusercontent.com/fpasquet/ai-sdk-harness/main/docs/public/screenshots/next-chat/sessions.png)
14
+
15
+ ## At a glance
16
+
17
+ `HarnessAgent` gives you a session and leaves the rest to you: which session a message goes to, what happens to a session nobody writes to, to a turn under way when your server stops, to the conversation the user wants to read again. A session manager does it:
18
+
19
+ ```ts
20
+ import { createSessionManager, sharedSandbox } from 'ai-sdk-harness-sessions';
21
+
22
+ const sessions = createSessionManager({
23
+ // The agent of a session, from what you keep with it.
24
+ agent: ({ metadata }) => agents[metadata.agent],
25
+ // One sandbox for every session, each with a port of its own for its bridge.
26
+ sandboxes: sharedSandbox({
27
+ open: openSandbox,
28
+ ports: Array.from({ length: 4 }, (_, i) => 4001 + i),
29
+ }),
30
+ // A session nobody writes to for 10 minutes is suspended; the next message resumes it.
31
+ idleTimeoutMs: 10 * 60_000,
32
+ });
33
+
34
+ // A route handler: the turn streams back to useChat.
35
+ await sessions.create({ id, metadata: { agent: 'reviewer' } });
36
+ const turn = await sessions.send(id, { message, abortSignal: request.signal });
37
+ return turn.toUIMessageStreamResponse();
38
+ ```
39
+
40
+ | What happens | What the manager does |
41
+ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
42
+ | A first message | Opens a sandbox, or a view of a shared one, and a harness session, in the background: a message sent meanwhile waits |
43
+ | A message while a turn runs | Refuses it: one turn at a time per session (`SessionConflictError`, status `busy`) |
44
+ | The turn asks approval for a tool call | Asks your `approve` option first, then keeps the session `awaiting-input` until `continue()` brings the answer |
45
+ | Nobody writes for `idleTimeoutMs` | Suspends the session: its harness session stops with its state, its sandbox is freed. The next message resumes it |
46
+ | Every sandbox slot is taken | Suspends the session idle the longest to make room |
47
+ | Your server stops (`shutdown()`) | Cuts the turns under way short, and suspends every session: the next start resumes them on their next message |
48
+ | Your server crashes | Marks the sessions it held `interrupted` at the next start: their conversation can still be read |
49
+ | You want the conversation again | `get(id)` gives it as `useChat` shows it, `list()` every session, `subscribe()` every change, for a list to follow |
50
+
51
+ ## Installation
52
+
53
+ ```bash
54
+ pnpm add ai-sdk-harness-sessions
55
+ ```
56
+
57
+ It works with the agents and sandboxes you already have: `@ai-sdk/harness` and `ai` are peer dependencies, and a sandbox package such as [`ai-sdk-sandbox-sbx`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-sbx) or [`ai-sdk-sandbox-cloud-run`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-cloud-run) gives the sandbox.
58
+
59
+ ## Usage
60
+
61
+ ### The manager
62
+
63
+ Create one manager for your server, and keep it: with Next.js, on `globalThis`, so that a reload of the module in development does not start a second one.
64
+
65
+ ```ts title="lib/sessions.ts"
66
+ import { createClaudeCode } from '@ai-sdk/harness-claude-code';
67
+ import { createHarnessSandboxTemplate, HarnessAgent } from '@ai-sdk/harness/agent';
68
+ import {
69
+ createSbxNetworkSandboxSession,
70
+ resumeSbxNetworkSandboxSession,
71
+ SbxSandboxNotFoundError,
72
+ } from 'ai-sdk-sandbox-sbx';
73
+ import { createSessionManager, sharedSandbox } from 'ai-sdk-harness-sessions';
74
+
75
+ const harness = createClaudeCode();
76
+ const agent = new HarnessAgent({ harness, model: 'claude-sonnet-5-5' });
77
+
78
+ /** The sandbox the sessions share: found again when it exists, created from a template otherwise. */
79
+ async function openSandbox() {
80
+ const settings = { sandboxId: 'my-app', ports: [4000] };
81
+ try {
82
+ const sandbox = await resumeSbxNetworkSandboxSession(settings);
83
+ // The bridges a previous run of the server left behind still hold their ports.
84
+ await sandbox.killAllProcesses();
85
+ return sandbox;
86
+ } catch (error) {
87
+ if (!(error instanceof SbxSandboxNotFoundError)) throw error;
88
+ }
89
+ const template = await createHarnessSandboxTemplate({ harnesses: [harness] });
90
+ return createSbxNetworkSandboxSession({ ...settings, template });
91
+ }
92
+
93
+ export const sessions = createSessionManager<{ title: string }>({
94
+ agent: () => agent,
95
+ sandboxes: sharedSandbox({
96
+ open: openSandbox,
97
+ ports: Array.from({ length: 4 }, (_, i) => 4001 + i),
98
+ }),
99
+ idleTimeoutMs: 10 * 60_000,
100
+ });
101
+
102
+ // Suspended rather than lost when the server is told to stop.
103
+ process.once('SIGTERM', () => void sessions.shutdown().finally(() => process.exit(0)));
104
+ ```
105
+
106
+ `agent` is called when a session starts and each time it resumes, with the session's `metadata`: what you keep with it, the agent it runs, a title, its owner. Build each agent once and return it again. The metadata is kept as JSON.
107
+
108
+ ### A route
109
+
110
+ The first message creates the session; every message is sent to it. The session keeps the conversation, so only the new message goes to the agent: `prompt` sends something else, a slash command expanded for instance, while the conversation keeps the message as it was written.
111
+
112
+ ```ts title="app/api/chat/route.ts"
113
+ import type { UIMessage } from 'ai';
114
+
115
+ import { SessionConflictError } from 'ai-sdk-harness-sessions';
116
+
117
+ import { sessions } from '@/lib/sessions';
118
+
119
+ export async function POST(request: Request) {
120
+ const { id, messages } = (await request.json()) as { id: string; messages: UIMessage[] };
121
+ const message = messages.at(-1)!;
122
+ if ((await sessions.get(id)) === undefined) {
123
+ await sessions.create({ id, metadata: { title: 'New conversation' } });
124
+ }
125
+ try {
126
+ const turn = await sessions.send(id, { message, abortSignal: request.signal });
127
+ return turn.toUIMessageStreamResponse();
128
+ } catch (error) {
129
+ if (SessionConflictError.isInstance(error)) return new Response(error.message, { status: 409 });
130
+ throw error;
131
+ }
132
+ }
133
+ ```
134
+
135
+ The turn is read to its end on your server as well: a user who closes the tab does not leave the session hanging, and the session is written either way. `abortSignal: request.signal` lets the stop button of `useChat` cut the turn short; leave it out for turns that go on without the client. `interrupt(id)` cuts a turn short from anywhere.
136
+
137
+ ### Showing the conversations again
138
+
139
+ ```ts
140
+ const conversations = await sessions.list(); // every session, without its messages
141
+ const conversation = await sessions.get(id); // with its messages, for useChat({ messages })
142
+ ```
143
+
144
+ `subscribe()` calls you with every change: a status, the end of a turn, a deletion. Send them to the page as server-sent events, and a list of conversations follows each one live:
145
+
146
+ ```ts title="app/api/sessions/events/route.ts"
147
+ export function GET(request: Request) {
148
+ const encoder = new TextEncoder();
149
+ const stream = new ReadableStream({
150
+ start(controller) {
151
+ const unsubscribe = sessions.subscribe((event) =>
152
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)),
153
+ );
154
+ request.signal.addEventListener('abort', () => {
155
+ unsubscribe();
156
+ controller.close();
157
+ });
158
+ },
159
+ });
160
+ return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });
161
+ }
162
+ ```
163
+
164
+ ## The lifecycle
165
+
166
+ ```mermaid
167
+ stateDiagram-v2
168
+ [*] --> preparing: create()
169
+ preparing --> idle: sandbox and harness session ready
170
+ preparing --> failed: could not start
171
+ failed --> preparing: next message
172
+ idle --> busy: send()
173
+ busy --> idle: the turn ends
174
+ busy --> awaiting_input: the turn asks for an approval
175
+ awaiting_input --> busy: continue()
176
+ idle --> suspended: idle for idleTimeoutMs, or shutdown()
177
+ awaiting_input --> suspended: idle for idleTimeoutMs, or shutdown()
178
+ suspended --> preparing: next message, or continue()
179
+ idle --> closed: close()
180
+ suspended --> closed: close(), or closeSuspendedAfterMs
181
+ preparing --> interrupted: the server crashed
182
+ idle --> interrupted: the server crashed
183
+ busy --> interrupted: the server crashed
184
+ ```
185
+
186
+ | Status | What it means |
187
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------ |
188
+ | `preparing` | Its sandbox and harness session are being opened, or brought back from `suspended`. A message sent meanwhile waits |
189
+ | `idle` | Ready for a message |
190
+ | `busy` | A turn is under way. Another message is refused until it ends |
191
+ | `awaiting-input` | The turn paused on a tool call that waits for you: an approval, or the result of a tool your application runs |
192
+ | `suspended` | Stopped, its state kept in the store and its sandbox freed. The next message, or `continue()`, brings it back |
193
+ | `closed` | Ended for good. Its conversation can still be read |
194
+ | `failed` | A new session that could not start: a sandbox that did not open, say. The next message tries again |
195
+ | `interrupted` | Cannot be brought back: the server stopped without suspending it. Its conversation can still be read |
196
+
197
+ A session that fails to resume stays `suspended`, with the reason in its `error`: the next message tries again from the same state.
198
+
199
+ ### Suspended, then resumed
200
+
201
+ A suspended session costs nothing but its record: its harness session is stopped, and what it needs to pick the conversation up is kept in the store. On the next message, the manager reattaches to its sandbox, its files where it left them, and resumes its harness session, which carries on the conversation where it was:
202
+
203
+ ![A conversation of the Next.js example resumed after a suspension: the agent remembers what it did](https://raw.githubusercontent.com/fpasquet/ai-sdk-harness/main/docs/public/screenshots/next-chat/resumed.png)
204
+
205
+ **What it shows:** the conversation was suspended from the list ("Suspend now", `suspend(id)`), its port handed back. The next message resumed it, and Claude Code answered from the conversation it had before.
206
+
207
+ **What the package does:** `suspend()` stops the harness session with `HarnessAgentSession.stop()`, writes the session `suspended` with its resume state, and suspends its sandbox lease. The next `send()` acquires the lease again (`resume: true`) and creates the harness session with `resumeFrom`.
208
+
209
+ ### Waiting for an approval
210
+
211
+ With `permissionMode: 'allow-reads'`, Claude Code asks before it edits a file or runs a command, and with `toolApproval` any runtime asks before it calls one of your tools. Codex does not ask before using its own tools yet: its harness adapter only takes `permissionMode: 'allow-all'` ([vercel/ai#22550](https://github.com/vercel/ai/issues/22550)). The turn pauses, and the session waits, `awaiting-input`, with what it waits for in `pendingInput`:
212
+
213
+ ![The Next.js example waiting for the approval of a shell command, the conversation marked "Needs you"](https://raw.githubusercontent.com/fpasquet/ai-sdk-harness/main/docs/public/screenshots/next-chat/approval.png)
214
+
215
+ `continue()` answers it. It takes the answers as `HarnessAgent.continueStream()` does, or reads them from the last message `useChat` sends back once `addToolApprovalResponse()` filled them in:
216
+
217
+ ```ts
218
+ // useChat({ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses })
219
+ // sends the conversation back once every approval is answered: its last message is the agent's.
220
+ const last = messages.at(-1)!;
221
+ const turn =
222
+ last.role === 'assistant'
223
+ ? await sessions.continue(id, { message: last })
224
+ : await sessions.send(id, { message: last });
225
+ ```
226
+
227
+ A session suspended while it waited keeps what it waits for: `continue()` resumes the session, without its paused turn, whose harness bridge the suspension stopped, and tells the agent the answers in a message. With Claude Code, see [Limitations](#limitations).
228
+
229
+ The agent's questions wait the same way: Claude Code's `AskUserQuestion` reaches you as a call of `askUserQuestions` in `pendingInput.toolCalls`, and its answers are the tool's output, `addToolOutput()` in `useChat`, `toolResultContinuations` otherwise. A session resumed without its turn tells the agent the answers in words, the labels of the options picked.
230
+
231
+ ### Answered by a policy
232
+
233
+ The `approve` option answers the approvals before anyone sees them. A verdict is sent at once and the turn goes on, in the same stream: the client sees the request, then its answer, then what the agent did. `undefined` leaves the approval to a person. [`ai-sdk-harness-approval`](https://ai-sdk-harness.pages.dev/docs/packages/harness-approval) gives one from a policy, with the "always allow" of each session kept in its metadata:
234
+
235
+ ```ts
236
+ import { type ApprovalGrant, defineApprovalPolicy } from 'ai-sdk-harness-approval';
237
+
238
+ const policy = defineApprovalPolicy({
239
+ commands: { allow: ['ls', 'git status', 'pnpm test'], deny: ['git push', 'rm -rf'] },
240
+ });
241
+
242
+ const sessions = createSessionManager<{ grants?: ApprovalGrant[] }>({
243
+ agent: () => new HarnessAgent({ harness, ...policy.agentSettings(harness) }),
244
+ sandboxes,
245
+ approve: policy.approver({ grants: ({ session }) => session.metadata.grants }),
246
+ });
247
+
248
+ // "Always allow": kept with the session, read by the next approvals.
249
+ await sessions.update(id, { metadata: (metadata) => ({ ...metadata, grants }) });
250
+ ```
251
+
252
+ When a pause holds several approvals and `approve` answers some, the session waits for the others: `pendingInput.approvals` carries the answers given in `decision`, and `continue()` sends them with the person's, which cannot overturn them. An approval asked again right after `approve` answered it, with the same input, is left to a person: a harness that does not take the answer would otherwise ask forever.
253
+
254
+ ## Sandboxes
255
+
256
+ Where each session runs is up to `sandboxes`:
257
+
258
+ | Strategy | What each session gets | When |
259
+ | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
260
+ | `sharedSandbox({ open, ports, stopWhenUnused })` | A view of one sandbox (`fork()`), with a port of its own for its bridge, and a working directory of its own | One user, or sessions that may see each other's files |
261
+ | `sandboxPerSession({ create, resume, maxSessions })` | A sandbox of its own, stopped when the session is suspended, destroyed when it is closed | Sessions that must not see each other: one per user or per repository |
262
+ | Your own `SessionSandboxes` | What `acquire(sessionId, { resume })` returns | A pool, a sandbox per user shared by their sessions… |
263
+
264
+ `sharedSandbox()` takes sandboxes that give views of themselves, `fork({ ports })`: those of `ai-sdk-sandbox-sbx` and `ai-sdk-sandbox-cloud-run`. A suspended session hands its port back and keeps its files in the sandbox. With `stopWhenUnused`, the sandbox itself is stopped once no session holds it, and opened again with the next one: on Cloud Run, a stopped sandbox costs its snapshot's storage alone.
265
+
266
+ ```ts
267
+ import { createSbxNetworkSandboxSession, resumeSbxNetworkSandboxSession } from 'ai-sdk-sandbox-sbx';
268
+ import { sandboxPerSession } from 'ai-sdk-harness-sessions';
269
+
270
+ const sandboxes = sandboxPerSession({
271
+ create: (sessionId) =>
272
+ createSbxNetworkSandboxSession({ sandboxId: `chat-${sessionId}`, ports: [4000], template }),
273
+ resume: (sessionId) =>
274
+ resumeSbxNetworkSandboxSession({ sandboxId: `chat-${sessionId}`, ports: [4000] }),
275
+ maxSessions: 8,
276
+ });
277
+ ```
278
+
279
+ ### How many sessions run at once
280
+
281
+ Claude Code and Codex run behind a bridge: a process the harness starts in the sandbox for each session, listening on a port of the sandbox that your server reaches over a WebSocket (published on `127.0.0.1` by `sbx`, tunnelled by the Cloud Run service). The harness takes the first port of the sandbox session it is given, so two sessions handed the same sandbox would both listen on it. `sharedSandbox()` gives each session a view of the sandbox with a port of its own: the same microVM, the same installed tools, a working directory of its own, and a port of `ports` for its bridge.
282
+
283
+ ```ts
284
+ sharedSandbox({ open: openSandbox, ports: Array.from({ length: 4 }, (_, i) => 4001 + i) }); // 4001 … 4004
285
+ ```
286
+
287
+ The numbers are any free ports inside the sandbox; their count is what matters. It bounds the sessions that are **live** at once — `preparing`, `idle`, `busy` or `awaiting-input`, each with a bridge and a runtime running —, not the conversations you keep: a `suspended` session holds no port. With four ports, ten conversations can be open, four live and six suspended until their next message.
288
+
289
+ When a fifth session starts, or a suspended one resumes:
290
+
291
+ | The four live sessions | What happens |
292
+ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
293
+ | One of them, at least, is `idle` | The one idle the longest is suspended, and the new session takes its port |
294
+ | None is `idle`: all `busy`, `preparing` or `awaiting-input` | Refused with `SessionCapacityError`: an HTTP API answers 503. A session waiting for an approval is not suspended to make room |
295
+ | `suspendIdleWhenFull: false` | Refused with `SessionCapacityError` as soon as the four ports are taken |
296
+
297
+ To run ten turns at the same time, give ten ports — `Array.from({ length: 10 }, (_, i) => 4001 + i)` — and the sandbox the CPUs and memory ten runtimes need (`cpus` and `memory` of `createSbxNetworkSandboxSession()`), or give each session a sandbox of its own with `sandboxPerSession()`, or spread the sessions over several shared sandboxes with a `SessionSandboxes` of your own. A short `idleTimeoutMs` frees the ports of the sessions nobody writes to: four ports then serve many more open conversations.
298
+
299
+ ## Keeping sessions
300
+
301
+ The manager writes every change to a `SessionStore`: the session with its conversation, and, while it is suspended, the state it resumes from. `createMemorySessionStore()`, the default, keeps them in memory, lost with the process. To keep them across restarts, implement the five methods on your database:
302
+
303
+ ```ts
304
+ import type { SessionStore } from 'ai-sdk-harness-sessions';
305
+
306
+ const store: SessionStore<Metadata> = {
307
+ save: async (record, resumeState) => {
308
+ await db.sessions.upsert(record);
309
+ // Kept while the session is suspended, dropped otherwise.
310
+ await (resumeState
311
+ ? db.resumeStates.upsert(record.id, resumeState)
312
+ : db.resumeStates.delete(record.id));
313
+ },
314
+ get: (id) => db.sessions.find(id),
315
+ list: () => db.sessions.findAll({ without: ['messages'] }),
316
+ getResumeState: (id) => db.resumeStates.find(id),
317
+ delete: async (id) => {
318
+ await db.sessions.delete(id);
319
+ await db.resumeStates.delete(id);
320
+ },
321
+ };
322
+ ```
323
+
324
+ A resume state holds secrets: the token of the harness bridge, the placeholders of the credentials. Keep it apart from what you show, as you would a session token. The [Next.js example](https://github.com/fpasquet/ai-sdk-harness/tree/main/examples/next-chat) keeps its sessions in JSON files (`lib/session-store.ts`).
325
+
326
+ Keep one manager per store: at its start, a manager takes the sessions the store holds as live for those of a process that crashed, and marks them `interrupted`.
327
+
328
+ ## Reference
329
+
330
+ ### `createSessionManager(options)`
331
+
332
+ | Option | Default | Description |
333
+ | ----------------------- | ------------------------ | --------------------------------------------------------------------------------------------------- |
334
+ | `agent` | required | The agent of a session, from `{ id, metadata }`: called at each start and resume |
335
+ | `sandboxes` | required | `sharedSandbox()`, `sandboxPerSession()` or your own `SessionSandboxes` |
336
+ | `store` | in memory | Where sessions are kept: a `SessionStore` |
337
+ | `idleTimeoutMs` | never | Suspend a session idle, or awaiting input, for this long |
338
+ | `closeSuspendedAfterMs` | never | Close a session suspended for this long |
339
+ | `turnTimeoutMs` | never | Cut a turn short after this long |
340
+ | `suspendIdleWhenFull` | `true` | When every sandbox slot is taken, suspend the session idle the longest rather than refuse a new one |
341
+ | `approve` | none | Answers an approval before anyone sees it: `{ approved, reason }`, or `undefined` to ask a person |
342
+ | `generateId` | `crypto.randomUUID` | The ids of new sessions and of the messages of their turns |
343
+ | `errorMessage` | `getHarnessErrorMessage` | What a client is told of an error in a turn; the session's `error` keeps the full message |
344
+ | `onError` | `console.error` | Called with what fails in the background: a start, a suspension, a write to the store |
345
+
346
+ | Method | What it does |
347
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------- |
348
+ | `create({ id, metadata })` | Opens a session, and returns it `preparing` |
349
+ | `send(id, options)` | Starts a turn in reply to a message (`message`, `prompt`, `abortSignal`): a `SessionTurn` |
350
+ | `continue(id, options)` | Resumes a paused turn with its answers (`message`, `toolApprovalContinuations`, `toolResultContinuations`) |
351
+ | `update(id, { metadata })` | Changes the metadata of a session, whatever its status: a value, or a function of the current one |
352
+ | `interrupt(id)` | Cuts the turn under way short |
353
+ | `suspend(id)` | Suspends an idle session now |
354
+ | `close(id)` | Ends a session for good |
355
+ | `delete(id)` | Closes a session, then forgets it |
356
+ | `get(id)`, `list()` | A session with its conversation; every session without |
357
+ | `subscribe(listener)` | Every change, `{ type: 'updated', session }` or `{ type: 'deleted', sessionId }` |
358
+ | `shutdown()` | Suspends every live session and shuts the sandboxes down |
359
+
360
+ A `SessionTurn` has the turn as a UI message `stream`, `toUIMessageStreamResponse()` for a route, and `done`, the session once the turn is over and written.
361
+
362
+ ### Errors
363
+
364
+ Each has an `isInstance()` that recognizes it whichever copy of the package threw it, as Next.js may load two.
365
+
366
+ - `SessionNotFoundError` (404): no session has this id. Carries `sessionId`.
367
+ - `SessionConflictError` (409): the session cannot do it in its status — a message while `busy` or `awaiting-input`, `continue()` with nothing to answer, anything once `closed` or `interrupted`. Carries `sessionId` and `status`.
368
+ - `SessionCapacityError` (503): every sandbox slot is taken by a session that is not idle. Carries `capacity`.
369
+
370
+ ## Limitations
371
+
372
+ - **One process per store.** The live sessions are held in the memory of one process: run the manager on one instance, or give each instance its own sessions.
373
+ - **A turn does not survive a crash.** A session the process held when it crashed is `interrupted`; a clean `shutdown()` suspends it instead. Streams that reconnect after a page reload are not supported yet: a reload cuts the turn its request carried when it passed `abortSignal: request.signal`.
374
+ - **Approvals after a resume, with Claude Code.** A resumed Claude Code session (`@ai-sdk/harness-claude-code` up to 1.0.152) sends "Continue." in place of the answers to the approvals it asks afterwards: the tool call is rejected, and asked again ([vercel/ai#22549](https://github.com/vercel/ai/issues/22549)). Approvals work in a session until it is suspended; a session that resumed asks again and again. A turn that was waiting for an approval when its session was suspended resumes without it: `continue()` tells the agent the answers in a message.
375
+ - **No approvals of Codex's own tools.** `@ai-sdk/harness-codex` (up to 1.0.150) runs Codex with approvals turned off and refuses any `permissionMode` but `'allow-all'`, though Codex itself can ask before a command or a patch ([vercel/ai#22550](https://github.com/vercel/ai/issues/22550)). Your own tools can still ask, through `toolApproval`.
376
+ - **A closed session's files stay in a shared sandbox**, in its working directory: remove them from your `close` handling when they matter.
377
+
378
+ ## Development
379
+
380
+ `pnpm --filter ai-sdk-harness-sessions test:e2e` runs real Claude Code sessions in a Docker Sandbox: two side by side, suspended and resumed, across a restart, and waiting for an approval; it needs `sbx` signed in and a Claude credential. `ai-sdk-harness-approval`'s end-to-end tests cover `approve`. See [CONTRIBUTING.md](https://github.com/fpasquet/ai-sdk-harness/blob/main/CONTRIBUTING.md).
381
+
382
+ ## License
383
+
384
+ [MIT](https://github.com/fpasquet/ai-sdk-harness/blob/main/LICENSE)
@@ -0,0 +1,31 @@
1
+ import type { HarnessV1NetworkSandboxSession } from '@ai-sdk/harness';
2
+ import type { HarnessAgentResumeSessionState, HarnessAgentSession } from '@ai-sdk/harness/agent';
3
+ import type { StreamTextResult, ToolApprovalResponse, ToolResultPart, ToolSet } from 'ai';
4
+ /** A turn's result, whatever the agent's runtime context and output. */
5
+ type AnyStreamTextResult = StreamTextResult<ToolSet, any, any>;
6
+ /** What the manager reads of a turn: a `StreamTextResult` is one. */
7
+ export type SessionStreamResult = Pick<AnyStreamTextResult, 'totalUsage' | 'toUIMessageStream'>;
8
+ /**
9
+ * What the manager needs of an agent: a `HarnessAgent` is one, whatever its harness, tools and
10
+ * settings.
11
+ */
12
+ export interface SessionAgent {
13
+ createSession(options: {
14
+ sessionId: string;
15
+ sandboxSession: HarnessV1NetworkSandboxSession;
16
+ resumeFrom?: HarnessAgentResumeSessionState;
17
+ abortSignal?: AbortSignal;
18
+ }): Promise<HarnessAgentSession>;
19
+ stream(options: {
20
+ session: HarnessAgentSession;
21
+ prompt: string;
22
+ abortSignal?: AbortSignal;
23
+ }): PromiseLike<SessionStreamResult>;
24
+ continueStream(options: {
25
+ session: HarnessAgentSession;
26
+ toolApprovalContinuations?: readonly ToolApprovalResponse[];
27
+ toolResultContinuations?: readonly ToolResultPart[];
28
+ abortSignal?: AbortSignal;
29
+ }): PromiseLike<SessionStreamResult>;
30
+ }
31
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,157 @@
1
+ import type { ToolApprovalResponse, ToolResultPart, UIMessage, UIMessageChunk } from 'ai';
2
+ import type { SessionAgent } from './agent.js';
3
+ import type { SessionSandboxes } from './sandboxes.js';
4
+ import type { ApprovalVerdict, PendingApproval, SessionRecord, SessionSummary } from './session.js';
5
+ import type { SessionStore } from './store.js';
6
+ export interface SessionManagerOptions<METADATA> {
7
+ /**
8
+ * The agent of a session, from its metadata: called when the session starts, and each time it
9
+ * resumes, so a resumed session runs the agent as it stands then. Build each agent once and
10
+ * return it again: a `HarnessAgent` is meant to be shared.
11
+ */
12
+ agent: (session: {
13
+ id: string;
14
+ metadata: METADATA;
15
+ }) => Promise<SessionAgent> | SessionAgent;
16
+ /** How sessions get a sandbox: `sharedSandbox()`, `sandboxPerSession()`, or your own. */
17
+ sandboxes: SessionSandboxes;
18
+ /** Where sessions are kept. Default: in memory, `createMemorySessionStore()`. */
19
+ store?: SessionStore<METADATA>;
20
+ /**
21
+ * Suspend a session idle, or awaiting input, for this long, in milliseconds: its harness session
22
+ * stops, its state is kept and its sandbox freed. The next message brings it back. Default:
23
+ * never.
24
+ */
25
+ idleTimeoutMs?: number;
26
+ /** Close a session suspended for this long, in milliseconds. Default: never. */
27
+ closeSuspendedAfterMs?: number;
28
+ /** Cut a turn short after this long, in milliseconds. Default: never. */
29
+ turnTimeoutMs?: number;
30
+ /**
31
+ * When every sandbox slot is taken, suspend the session idle the longest to make room for a new
32
+ * one, or a resume, rather than refusing it with `SessionCapacityError`. Default: `true`.
33
+ */
34
+ suspendIdleWhenFull?: boolean;
35
+ /**
36
+ * Answers the approvals a turn asks for before anyone sees them: an approval policy, the grants
37
+ * of the session. A verdict is sent at once and the turn goes on, in the same stream, the client
38
+ * seeing the answer; `undefined` leaves the approval to a person, the session `awaiting-input`.
39
+ * Default: every approval waits for a person.
40
+ *
41
+ * An approval asked again right after it was answered, with the same input, is left to a
42
+ * person: a harness that does not take the answer (see the README) would ask forever.
43
+ */
44
+ approve?: (approval: PendingApproval, context: {
45
+ session: SessionSummary<METADATA>;
46
+ }) => ApprovalVerdict | PromiseLike<ApprovalVerdict | undefined> | undefined;
47
+ /** The ids of new sessions and of the messages of their turns. Default: `crypto.randomUUID`. */
48
+ generateId?: () => string;
49
+ /**
50
+ * What a client is told of an error in a turn. Default: `getHarnessErrorMessage` of
51
+ * `@ai-sdk/harness`. The session's `error` keeps the full message.
52
+ */
53
+ errorMessage?: (error: unknown) => string;
54
+ /**
55
+ * Called with what fails in the background: a start, a suspension, a write to the store. Default:
56
+ * `console.error`.
57
+ */
58
+ onError?: (error: unknown, context: {
59
+ action: string;
60
+ sessionId?: string;
61
+ }) => void;
62
+ }
63
+ /** A user message: `useChat`'s last message, or its text. */
64
+ export interface SendOptions {
65
+ message: string | UIMessage;
66
+ /**
67
+ * What the agent is sent, when it differs from what the conversation keeps: a slash command
68
+ * expanded, for instance. Default: the text of `message`.
69
+ */
70
+ prompt?: string;
71
+ /** Aborts the turn: the request's signal, so that the client's stop button stops it. */
72
+ abortSignal?: AbortSignal;
73
+ }
74
+ /** The answers to what a session `awaiting-input` waits for. */
75
+ export interface ContinueOptions {
76
+ /**
77
+ * The last assistant message as `useChat` sends it back, once `addToolApprovalResponse()` or
78
+ * `addToolOutput()` filled the answers in: they are read from it.
79
+ */
80
+ message?: UIMessage;
81
+ toolApprovalContinuations?: readonly ToolApprovalResponse[];
82
+ toolResultContinuations?: readonly ToolResultPart[];
83
+ abortSignal?: AbortSignal;
84
+ }
85
+ /** A turn under way. */
86
+ export interface SessionTurn<METADATA = unknown> {
87
+ /** The turn as a UI message stream, what `useChat` reads. */
88
+ readonly stream: ReadableStream<UIMessageChunk>;
89
+ /** {@link stream} as the response of a route handler. */
90
+ toUIMessageStreamResponse(init?: ResponseInit): Response;
91
+ /** The session once the turn is over and written: `idle`, or `awaiting-input`. */
92
+ readonly done: Promise<SessionSummary<METADATA>>;
93
+ }
94
+ /** What happens to the sessions, for a list or a notification to follow. */
95
+ export type SessionEvent<METADATA = unknown> = {
96
+ /** The session changed: its status, or its conversation at the end of a turn. */
97
+ type: 'updated';
98
+ session: SessionSummary<METADATA>;
99
+ } | {
100
+ type: 'deleted';
101
+ sessionId: string;
102
+ };
103
+ /**
104
+ * The sessions of an application with its coding agents: their whole lifecycle, from the first
105
+ * message to the last, across suspensions and restarts. See {@link createSessionManager}.
106
+ */
107
+ export interface SessionManager<METADATA = unknown> {
108
+ /**
109
+ * Opens a session and returns at once, `preparing`: its sandbox and its harness session are
110
+ * opened in the background — minutes the very first time, when the harness is installed in the
111
+ * sandbox. A message sent meanwhile waits for them.
112
+ */
113
+ create(options: {
114
+ id?: string;
115
+ metadata: METADATA;
116
+ }): Promise<SessionSummary<METADATA>>;
117
+ /** The session with its conversation: a live one as it stands right now. */
118
+ get(id: string): Promise<SessionRecord<METADATA> | undefined>;
119
+ /** Every session, the most recently updated first. */
120
+ list(): Promise<SessionSummary<METADATA>[]>;
121
+ /**
122
+ * Starts a turn in reply to a user message, resuming the session first when it is suspended, or
123
+ * starting it again when it `failed`. One turn at a time: a session `busy` or `awaiting-input`
124
+ * refuses it with `SessionConflictError`.
125
+ */
126
+ send(id: string, options: SendOptions): Promise<SessionTurn<METADATA>>;
127
+ /**
128
+ * Resumes the turn a session paused, `awaiting-input`, with the answers it waits for: approvals
129
+ * of tool calls, results of tools your application runs. A session suspended meanwhile is
130
+ * resumed first, its turn with it.
131
+ */
132
+ continue(id: string, options: ContinueOptions): Promise<SessionTurn<METADATA>>;
133
+ /**
134
+ * Changes what your application keeps with a session, its metadata, whatever its status: a
135
+ * title, the grants of an approval policy… Given a function, it is called with the metadata as
136
+ * they stand.
137
+ */
138
+ update(id: string, changes: {
139
+ metadata: ((metadata: METADATA) => METADATA) | METADATA;
140
+ }): Promise<SessionSummary<METADATA>>;
141
+ /** Cuts the turn under way short, and resolves once the session is written. */
142
+ interrupt(id: string): Promise<void>;
143
+ /** Suspends a session idle or awaiting input, as idleness would. */
144
+ suspend(id: string): Promise<SessionSummary<METADATA>>;
145
+ /** Ends a session for good: its harness session and what it holds of its sandbox go. */
146
+ close(id: string): Promise<SessionSummary<METADATA>>;
147
+ /** Closes a session, then forgets it. */
148
+ delete(id: string): Promise<void>;
149
+ /** Calls `listener` with every change of every session; returns what unsubscribes it. */
150
+ subscribe(listener: (event: SessionEvent<METADATA>) => void): () => void;
151
+ /**
152
+ * Suspends every live session, cutting turns under way short, so that the next start of the
153
+ * application resumes them; then shuts the sandboxes down. Call it when the process is told to
154
+ * stop. The manager refuses everything afterwards.
155
+ */
156
+ shutdown(): Promise<void>;
157
+ }
@@ -0,0 +1 @@
1
+ export {};