@alpacakit/agents 0.1.0-beta.39 → 0.2.0-beta.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.
Files changed (53) hide show
  1. package/README.md +121 -1
  2. package/dist/agents/claude-session.d.ts +37 -0
  3. package/dist/agents/claude-session.d.ts.map +1 -0
  4. package/dist/agents/claude-session.js +419 -0
  5. package/dist/agents/claude-stream.d.ts +65 -0
  6. package/dist/agents/claude-stream.d.ts.map +1 -0
  7. package/dist/agents/claude-stream.js +111 -0
  8. package/dist/agents/claude.d.ts +2 -1
  9. package/dist/agents/claude.d.ts.map +1 -1
  10. package/dist/agents/claude.js +6 -107
  11. package/dist/agents/codex-session.d.ts +52 -0
  12. package/dist/agents/codex-session.d.ts.map +1 -0
  13. package/dist/agents/codex-session.js +495 -0
  14. package/dist/agents/codex.d.ts +2 -1
  15. package/dist/agents/codex.d.ts.map +1 -1
  16. package/dist/agents/codex.js +3 -1
  17. package/dist/codex/app-server.d.ts +50 -1
  18. package/dist/codex/app-server.d.ts.map +1 -1
  19. package/dist/codex/app-server.js +130 -27
  20. package/dist/codex/index.d.ts +3 -2
  21. package/dist/codex/index.d.ts.map +1 -1
  22. package/dist/codex/index.js +2 -1
  23. package/dist/codex/protocol.d.ts +101 -0
  24. package/dist/codex/protocol.d.ts.map +1 -1
  25. package/dist/codex/protocol.js +78 -0
  26. package/dist/core/process-close.d.ts +11 -0
  27. package/dist/core/process-close.d.ts.map +1 -0
  28. package/dist/core/process-close.js +27 -0
  29. package/dist/core/session.d.ts +100 -0
  30. package/dist/core/session.d.ts.map +1 -0
  31. package/dist/core/session.js +29 -0
  32. package/dist/host/session-files.d.ts +22 -0
  33. package/dist/host/session-files.d.ts.map +1 -0
  34. package/dist/host/session-files.js +91 -0
  35. package/dist/index.d.ts +7 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -0
  38. package/dist/internal/controlled-process/supervisor-runtime.js +27 -1
  39. package/dist/kernel/definition.d.ts +11 -2
  40. package/dist/kernel/definition.d.ts.map +1 -1
  41. package/dist/kernel/facets.d.ts +38 -1
  42. package/dist/kernel/facets.d.ts.map +1 -1
  43. package/dist/kernel/facets.js +20 -0
  44. package/dist/kernel/runtime.d.ts +10 -6
  45. package/dist/kernel/runtime.d.ts.map +1 -1
  46. package/dist/kernel/runtime.js +4 -1
  47. package/dist/kernel/session.d.ts +97 -0
  48. package/dist/kernel/session.d.ts.map +1 -0
  49. package/dist/kernel/session.js +255 -0
  50. package/dist/testing/fake-exec.d.ts +6 -0
  51. package/dist/testing/fake-exec.d.ts.map +1 -1
  52. package/dist/testing/fake-exec.js +3 -1
  53. package/package.json +2 -2
package/README.md CHANGED
@@ -150,6 +150,109 @@ Each recovery call performs one authenticated termination convergence. A
150
150
  typed `pending` result invokes no lifecycle callback; the host owns the
151
151
  cancellable retry cadence and calls `recover` again when appropriate.
152
152
 
153
+ ## Sessions: hold a conversation and talk to it (`agent.session`)
154
+
155
+ `run()` is one prompt, one process, one answer. A **Session** is the other
156
+ shape a host offers: one Claude (`claude -p --input-format stream-json`) or
157
+ Codex (`codex app-server` thread) process held open across turns, which a
158
+ UI or an orchestrator keeps talking to. `agent.session` is `null` when the
159
+ definition has no session facet; `claude()` and `codex()` provide one.
160
+
161
+ ```ts
162
+ const runtime = createAgentRuntime({ agents: [claude(), codex()], baseEnv: processEnv() });
163
+ const sessions = runtime.agent("codex").session!;
164
+
165
+ const session = await sessions.open({
166
+ workspace: "/work/repo",
167
+ permissions: {
168
+ approvalPolicy: "on-request",
169
+ approvalsReviewer: "user",
170
+ sandbox: { type: "read-only" },
171
+ },
172
+ });
173
+ void (async () => {
174
+ for await (const event of session.events) {
175
+ // turn_started / message / tool / approval_requested / turn_ended / exited
176
+ if (event.type === "approval_requested") await session.respond(event.requestId, "accept");
177
+ }
178
+ })();
179
+ const turn = await session.send("Summarize the failing tests."); // { turnId, outcome, text }
180
+ await session.steer("Also list the flaky ones."); // into the running turn
181
+ await session.interrupt(); // turn ends with outcome "interrupted"
182
+ await session.close(); // EOF, then signals; releases the host's lock
183
+
184
+ const facts = await sessions.inspect(session.id, { homeDir }); // { workspace, lastPermissions, owner, archived } | null
185
+ const again = await sessions.resume(session.id, { workspace: facts!.workspace, permissions: facts!.lastPermissions! });
186
+ // An archived Codex thread resumes only with `unarchive: true` (see below).
187
+ ```
188
+
189
+ `send()` starts one turn and resolves when the host ends it; exactly one
190
+ turn runs at a time (`send` needs idle — including while a Codex
191
+ `turn/start` is still unanswered — and `steer` / `interrupt` need a running
192
+ turn; anything else is a typed `invalid_input`). `Session.id` is the host's
193
+ own conversation id (Claude `session_id`, Codex thread id), so it is what
194
+ the host's files, `--resume`, and `thread/resume` know.
195
+
196
+ `approval_requested.decisions` is always `["accept", "decline"]`: Claude's
197
+ `can_use_tool` takes allow or deny, and Codex takes `decline` for every
198
+ request even when its own `availableDecisions` lists only `accept` plus
199
+ amendment / `cancel` variants. Requests belong to the turn that raised
200
+ them — once the turn ends, `respond` for them is an `invalid_input`; after
201
+ the session ended it is "session is closed" for both hosts.
202
+
203
+ Permissions are **per host**, never the neutral `Sandbox` of `run()`:
204
+ `ClaudeSessionPermissions { mode }` (spelled as the transcript's
205
+ `permissionMode`; the CLI flag spells `default` as `manual`, and
206
+ `bypassPermissions` also passes `--allow-dangerously-skip-permissions`;
207
+ every Claude session passes `--permission-prompt-tool stdio` so approval
208
+ prompts reach `events` instead of being auto-denied) and `CodexSessionPermissions
209
+ { approvalPolicy, approvalsReviewer, sandbox }` with `sandbox` being
210
+ `read-only`, `danger-full-access`, or `workspace-write { writableRoots,
211
+ networkAccess, excludeTmpdirEnvVar, excludeSlashTmp }` (sent as config.toml
212
+ overrides on `thread/start` / `thread/resume`). `resume` always passes them
213
+ explicitly: a Codex `thread/resume` without `sandbox` ran as
214
+ danger-full-access in the lab.
215
+
216
+ Facts hosts write that `inspect` reads (through `HostLocation.homeDir`, never
217
+ `os.homedir()`): Claude `~/.claude/projects/*/<id>.jsonl` (first `cwd`, last
218
+ `permissionMode`) and `~/.claude/sessions/<pid>.json` (`owner.pid`,
219
+ `owner.entrypoint`); Codex `~/.codex/sessions/YYYY/MM/DD/rollout-*-<id>.jsonl`
220
+ or `archived_sessions/` (`archived`), `session_meta.cwd`, last
221
+ `turn_context`, and `thread-writer-locks/<id>.lock` (`owner` with `pid: null`
222
+ — the lock does not say who). `inspect` reports what is recorded; it does not
223
+ probe whether a recorded pid is alive.
224
+
225
+ **Codex trust note.** `open()` with a writable sandbox (`workspace-write`,
226
+ `danger-full-access`) makes Codex append a `trust_level` entry for the
227
+ workspace to `~/.codex/config.toml` (observed with codex 0.153.4);
228
+ `read-only` does not. Surface that before offering a writable `open()`.
229
+ Whether `resume()` with a writable sandbox appends the same entry is
230
+ **unverified** (the lab only resumed with `read-only`).
231
+
232
+ **Codex archive note.** `resume()` of an archived thread fails by default
233
+ with the server's "is archived" message (`inspect(id).archived` tells you
234
+ beforehand). Pass `unarchive: true` to `thread/unarchive` it first — that
235
+ also moves the thread back into the ChatGPT app's active list, which is
236
+ why it is opt-in. Claude has no archive; the option is ignored there.
237
+
238
+ Design conditions this facet keeps (T60 v3):
239
+
240
+ - C1 — permissions are host-typed; the neutral `Sandbox` never learns `workspace-write`.
241
+ - C2 — a Session holds a plain `Exec` child with `keepStdinOpen`; it does not go through `ControlledExec`. If the embedding process restarts, `resume` is the recovery path.
242
+ - C3 — the app-server client never kills the process on a request timeout; only that request fails. `close()` is the one place the process is ended.
243
+ - C4 — every host-file read goes through `HostLocation.homeDir`.
244
+ - C5 — Claude approvals were verified in the lab (`--permission-prompt-tool stdio` → `control_request` / `can_use_tool` → `control_response` allow/deny), so `respond` is supported for both hosts.
245
+ - C6 — consumers pin an exact version of this package and upgrade libs first, then the consumer, when a host CLI changes its protocol.
246
+
247
+ Not supported (by choice, or unverified):
248
+
249
+ - Claude control requests other than `can_use_tool` are ignored (logged) — if the CLI ever waits on one, that turn hangs until `interrupt()`; Codex server requests other than `item/commandExecution/requestApproval` and `item/fileChange/requestApproval` are refused with JSON-RPC -32601 so a turn never hangs on them. Neither case has been observed in the lab.
250
+ - Claude's stream carries no turn id, so a `steer()` that lands after the host has already ended the turn is taken by the CLI as the next turn's prompt; the following `send()` then resolves with that orphan turn's result. Steer only while a `tool` event has shown the turn is still working.
251
+ - Decisions are `accept` / `decline` only — no `acceptForSession`, execpolicy amendments, or `cancel`; Codex `approvalPolicy` is `untrusted` / `on-request` / `never` (the granular object form and `externalSandbox` decode to `lastPermissions: null` in `inspect`).
252
+ - No per-turn limits or guards on a Session; use `interrupt()`. `open()` takes no `AbortSignal`; use `close()`.
253
+ - `claude({ customizations: "disabled" })` sessions pass `--safe-mode --strict-mcp-config` but keep session persistence (a Session is the recorded conversation).
254
+ - The Claude child needs `USER` in `baseEnv` (keychain lookup); with only `PATH` / `HOME` the CLI reports "Not logged in".
255
+
153
256
  ## Structured output
154
257
 
155
258
  Any Standard Schema validator (zod v4 etc.) works. Repair happens within
@@ -303,7 +406,24 @@ installed skills and MCP servers remains in progress.
303
406
  | `/config/xdg` | XDG Base Directory config-root preset (general apps) |
304
407
  | `/config/extension` | custom agent-config kinds (`defineAgentConfigType`) |
305
408
  | `/testing` | `fakeExec`, `fakeClock`, `fakeAgent`, `fakeSecretResolver` — no subprocess ever spawns |
306
- | `/codex` | codex app-server JSON-RPC client (advanced) |
409
+ | `/codex` | codex app-server JSON-RPC client: typed requests, `incoming` notifications / server requests, `respond` (advanced) |
410
+
411
+ ## Breaking changes in 0.2.0
412
+
413
+ - `Agent.session` (and `AgentDefinition.session`) are new; `AgentDefinition`
414
+ and `Agent` gained a session-permissions type parameter, and `AgentRuntime`
415
+ a second parameter for the definition list so `runtime.agent(id).session`
416
+ is typed per agent (defaults keep existing annotations compiling).
417
+ `claude()` / `codex()` return definitions typed with their permission
418
+ shapes.
419
+ - `@alpacakit/agents/codex`: `CodexAppServer` gained `incoming`, `respond`,
420
+ `exit`, and `stderrTail`; a request timeout no longer kills the server
421
+ process; `close()` ends stdin and waits for the child to exit, escalating
422
+ to SIGTERM after 5 s and SIGKILL after another 5 s. Host-plane calls
423
+ (`agent.skills.list`, `agent.mcp.list` for codex) therefore take up to
424
+ 10 s longer than before when the server hangs, instead of being killed at
425
+ once. `thread/*` and `turn/*` methods are registered.
426
+ - `run()` behavior is unchanged.
307
427
 
308
428
  ## Status
309
429
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Claude session adapter: one `claude -p --input-format stream-json
3
+ * --output-format stream-json` process held open across turns.
4
+ *
5
+ * Protocol facts (claude 2.1.270, T52 / T61 lab runs):
6
+ * - a turn is one user line `{"type":"user","message":{"role":"user","content":…}}`
7
+ * and ends with the `result` line; a user line written mid-turn is absorbed
8
+ * into the running turn at its next tool boundary (`steer`);
9
+ * - `{"type":"control_request","request_id":…,"request":{"subtype":"interrupt"}}`
10
+ * is answered by a `control_response`, and the turn then ends with
11
+ * `is_error: true` / `terminal_reason: "aborted_tools"`;
12
+ * - approval prompts reach stdout only with `--permission-prompt-tool stdio`, as
13
+ * `control_request` / `can_use_tool`, and are answered with a
14
+ * `control_response` whose `behavior` is `allow` (+ `updatedInput`) or `deny`;
15
+ * - `--session-id <uuid>` fixes the id before the first turn (no `init` line
16
+ * is emitted until then), `--resume <id>` continues a recorded one;
17
+ * - the child needs `USER` in its env: without it the CLI reports "Not logged in".
18
+ */
19
+ import { type SessionCapability } from "../kernel/session.js";
20
+ export declare const CLAUDE_PERMISSION_MODE: {
21
+ readonly default: "default";
22
+ readonly acceptEdits: "acceptEdits";
23
+ readonly auto: "auto";
24
+ readonly bypassPermissions: "bypassPermissions";
25
+ readonly dontAsk: "dontAsk";
26
+ readonly plan: "plan";
27
+ };
28
+ export type ClaudePermissionMode = (typeof CLAUDE_PERMISSION_MODE)[keyof typeof CLAUDE_PERMISSION_MODE];
29
+ /** Spelled as the transcript's `permissionMode` records it. */
30
+ export type ClaudeSessionPermissions = {
31
+ readonly mode: ClaudePermissionMode;
32
+ };
33
+ export type ClaudeSessionOptions = {
34
+ readonly customizations: "inherit" | "disabled";
35
+ };
36
+ export declare function claudeSessions(options: ClaudeSessionOptions): SessionCapability<ClaudeSessionPermissions>;
37
+ //# sourceMappingURL=claude-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-session.d.ts","sourceRoot":"","sources":["../../src/agents/claude-session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAkCH,OAAO,EAGL,KAAK,iBAAiB,EAKvB,MAAM,sBAAsB,CAAC;AAG9B,eAAO,MAAM,sBAAsB;;;;;;;CAOzB,CAAC;AAEX,MAAM,MAAM,oBAAoB,GAC9B,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,OAAO,sBAAsB,CAAC,CAAC;AAEvE,+DAA+D;AAC/D,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;CACrC,CAAC;AA6EF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,cAAc,EAAE,SAAS,GAAG,UAAU,CAAC;CACjD,CAAC;AA4YF,wBAAgB,cAAc,CAC5B,OAAO,EAAE,oBAAoB,GAC5B,iBAAiB,CAAC,wBAAwB,CAAC,CAoB7C"}
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Claude session adapter: one `claude -p --input-format stream-json
3
+ * --output-format stream-json` process held open across turns.
4
+ *
5
+ * Protocol facts (claude 2.1.270, T52 / T61 lab runs):
6
+ * - a turn is one user line `{"type":"user","message":{"role":"user","content":…}}`
7
+ * and ends with the `result` line; a user line written mid-turn is absorbed
8
+ * into the running turn at its next tool boundary (`steer`);
9
+ * - `{"type":"control_request","request_id":…,"request":{"subtype":"interrupt"}}`
10
+ * is answered by a `control_response`, and the turn then ends with
11
+ * `is_error: true` / `terminal_reason: "aborted_tools"`;
12
+ * - approval prompts reach stdout only with `--permission-prompt-tool stdio`, as
13
+ * `control_request` / `can_use_tool`, and are answered with a
14
+ * `control_response` whose `behavior` is `allow` (+ `updatedInput`) or `deny`;
15
+ * - `--session-id <uuid>` fixes the id before the first turn (no `init` line
16
+ * is emitted until then), `--resume <id>` continues a recorded one;
17
+ * - the child needs `USER` in its env: without it the CLI reports "Not logged in".
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import path from "node:path";
21
+ import { AGENT_ERROR_CODE, AgentError, TIMEOUT_KIND } from "../core/error.js";
22
+ import { readJsonRecord, readNumber, readRecord, readString, } from "../core/json.js";
23
+ import { redactSecrets } from "../core/redact.js";
24
+ import { AGENT_EVENT_TYPE } from "../core/run.js";
25
+ import { APPROVAL_DECISION, SESSION_EVENT_TYPE, SESSION_OWNER_KIND, TURN_OUTCOME, } from "../core/session.js";
26
+ import { fileExists, readDirectoryNames, readJsonFile, scanJsonlFirstLast, } from "../host/session-files.js";
27
+ import { assertHostSessionId, SessionProcess, SessionState, sessionStateError, } from "../kernel/session.js";
28
+ import { CLAUDE, parseClaudeLine } from "./claude-stream.js";
29
+ export const CLAUDE_PERMISSION_MODE = {
30
+ default: "default",
31
+ acceptEdits: "acceptEdits",
32
+ auto: "auto",
33
+ bypassPermissions: "bypassPermissions",
34
+ dontAsk: "dontAsk",
35
+ plan: "plan",
36
+ };
37
+ const CLAUDE_SESSION = {
38
+ cli: {
39
+ inputFormat: "--input-format",
40
+ permissionPromptTool: "--permission-prompt-tool",
41
+ stdio: "stdio",
42
+ resume: "--resume",
43
+ sessionId: "--session-id",
44
+ allowDangerouslySkipPermissions: "--allow-dangerously-skip-permissions",
45
+ /** The CLI spells the transcript's `default` mode `manual` on its flag. */
46
+ manualPermissionMode: "manual",
47
+ },
48
+ stream: {
49
+ type: {
50
+ user: "user",
51
+ assistant: "assistant",
52
+ result: "result",
53
+ controlRequest: "control_request",
54
+ controlResponse: "control_response",
55
+ },
56
+ field: {
57
+ type: "type",
58
+ message: "message",
59
+ role: "role",
60
+ content: "content",
61
+ request: "request",
62
+ requestId: "request_id",
63
+ response: "response",
64
+ subtype: "subtype",
65
+ error: "error",
66
+ toolName: "tool_name",
67
+ description: "description",
68
+ input: "input",
69
+ isError: "is_error",
70
+ result: "result",
71
+ behavior: "behavior",
72
+ updatedInput: "updatedInput",
73
+ },
74
+ subtype: {
75
+ interrupt: "interrupt",
76
+ canUseTool: "can_use_tool",
77
+ success: "success",
78
+ error: "error",
79
+ },
80
+ behavior: { allow: "allow", deny: "deny" },
81
+ },
82
+ files: {
83
+ root: ".claude",
84
+ projects: "projects",
85
+ sessions: "sessions",
86
+ transcriptExtension: ".jsonl",
87
+ registryExtension: ".json",
88
+ cwd: "cwd",
89
+ permissionMode: "permissionMode",
90
+ sessionId: "sessionId",
91
+ pid: "pid",
92
+ entrypoint: "entrypoint",
93
+ },
94
+ };
95
+ const CONTROL_RESPONSE_TIMEOUT_MS = 30_000;
96
+ const DECLINED_MESSAGE = "Declined by the session owner";
97
+ const CLAUDE_APPROVAL_DECISIONS = [
98
+ APPROVAL_DECISION.accept,
99
+ APPROVAL_DECISION.decline,
100
+ ];
101
+ function sessionArgs(permissions, target, options) {
102
+ const mode = permissions.mode === CLAUDE_PERMISSION_MODE.default
103
+ ? CLAUDE_SESSION.cli.manualPermissionMode
104
+ : permissions.mode;
105
+ return [
106
+ CLAUDE.cli.prompt,
107
+ CLAUDE_SESSION.cli.inputFormat,
108
+ CLAUDE.cli.streamJson,
109
+ CLAUDE.cli.outputFormat,
110
+ CLAUDE.cli.streamJson,
111
+ CLAUDE.cli.verbose,
112
+ CLAUDE.cli.permissionMode,
113
+ mode,
114
+ ...(permissions.mode === CLAUDE_PERMISSION_MODE.bypassPermissions
115
+ ? [CLAUDE_SESSION.cli.allowDangerouslySkipPermissions]
116
+ : []),
117
+ CLAUDE_SESSION.cli.permissionPromptTool,
118
+ CLAUDE_SESSION.cli.stdio,
119
+ // A session's whole point is the recorded conversation, so `disabled`
120
+ // drops only the customization flags, never `--no-session-persistence`.
121
+ ...(options.customizations === "disabled"
122
+ ? [CLAUDE.cli.safeMode, CLAUDE.cli.strictMcpConfig]
123
+ : []),
124
+ target.kind === "resume"
125
+ ? CLAUDE_SESSION.cli.resume
126
+ : CLAUDE_SESSION.cli.sessionId,
127
+ target.sessionId,
128
+ ];
129
+ }
130
+ class ClaudeSession {
131
+ id;
132
+ process;
133
+ ctx;
134
+ state = new SessionState(CLAUDE.id);
135
+ pendingApprovals = new Map();
136
+ pendingControls = new Map();
137
+ reading;
138
+ turnCount = 0;
139
+ interruptRequested = false;
140
+ constructor(id, process, ctx) {
141
+ this.id = id;
142
+ this.process = process;
143
+ this.ctx = ctx;
144
+ this.reading = this.read();
145
+ }
146
+ get events() {
147
+ return this.state.events;
148
+ }
149
+ async send(text) {
150
+ this.state.requireIdle();
151
+ this.turnCount += 1;
152
+ const turn = this.state.beginTurn(String(this.turnCount));
153
+ this.writeUserLine(text);
154
+ return turn;
155
+ }
156
+ async steer(text) {
157
+ this.state.requireActive();
158
+ this.writeUserLine(text);
159
+ }
160
+ async interrupt() {
161
+ this.state.requireActive();
162
+ const requestId = randomUUID();
163
+ this.interruptRequested = true;
164
+ const answered = this.awaitControlResponse(requestId);
165
+ this.process.writeLine(JSON.stringify({
166
+ type: CLAUDE_SESSION.stream.type.controlRequest,
167
+ [CLAUDE_SESSION.stream.field.requestId]: requestId,
168
+ request: { subtype: CLAUDE_SESSION.stream.subtype.interrupt },
169
+ }));
170
+ try {
171
+ await answered;
172
+ }
173
+ catch (error) {
174
+ // An unanswered interrupt must not color the next turn's outcome.
175
+ this.interruptRequested = false;
176
+ throw error;
177
+ }
178
+ }
179
+ async respond(requestId, decision) {
180
+ this.state.requireOpen();
181
+ if (!this.pendingApprovals.has(requestId)) {
182
+ throw sessionStateError(CLAUDE.id, `no pending approval request "${requestId}"`);
183
+ }
184
+ const input = this.pendingApprovals.get(requestId);
185
+ this.pendingApprovals.delete(requestId);
186
+ const response = decision === APPROVAL_DECISION.accept
187
+ ? {
188
+ behavior: CLAUDE_SESSION.stream.behavior.allow,
189
+ updatedInput: input,
190
+ }
191
+ : {
192
+ behavior: CLAUDE_SESSION.stream.behavior.deny,
193
+ message: DECLINED_MESSAGE,
194
+ };
195
+ this.process.writeLine(JSON.stringify({
196
+ type: CLAUDE_SESSION.stream.type.controlResponse,
197
+ response: {
198
+ subtype: CLAUDE_SESSION.stream.subtype.success,
199
+ [CLAUDE_SESSION.stream.field.requestId]: requestId,
200
+ response,
201
+ },
202
+ }));
203
+ }
204
+ async close() {
205
+ await this.process.close();
206
+ await this.reading;
207
+ }
208
+ writeUserLine(text) {
209
+ this.process.writeLine(JSON.stringify({
210
+ type: CLAUDE_SESSION.stream.type.user,
211
+ message: { role: CLAUDE_SESSION.stream.type.user, content: text },
212
+ }));
213
+ }
214
+ awaitControlResponse(requestId) {
215
+ return new Promise((resolve, reject) => {
216
+ const cancelTimeout = this.ctx.clock.setTimeout(() => {
217
+ this.pendingControls.delete(requestId);
218
+ reject(new AgentError({
219
+ code: AGENT_ERROR_CODE.timeout,
220
+ classification: AgentError.agentFailure(AGENT_ERROR_CODE.timeout),
221
+ message: "Claude did not answer the control request in time",
222
+ data: {
223
+ kind: TIMEOUT_KIND.total,
224
+ limitMs: CONTROL_RESPONSE_TIMEOUT_MS,
225
+ },
226
+ agentId: CLAUDE.id,
227
+ }));
228
+ }, CONTROL_RESPONSE_TIMEOUT_MS);
229
+ this.pendingControls.set(requestId, { resolve, reject, cancelTimeout });
230
+ });
231
+ }
232
+ async read() {
233
+ try {
234
+ for await (const line of this.process.lines()) {
235
+ const record = readJsonRecord(line);
236
+ if (record !== null) {
237
+ this.handleRecord(record, line);
238
+ }
239
+ }
240
+ }
241
+ catch (error) {
242
+ this.ctx.logger.log("error", "claude session reader failed", error);
243
+ }
244
+ finally {
245
+ // Whatever happened above, the stream must end so `events` consumers
246
+ // and `close()` are released.
247
+ const exit = await this.process.exit;
248
+ for (const pending of this.pendingControls.values()) {
249
+ pending.cancelTimeout();
250
+ pending.reject(sessionStateError(CLAUDE.id, "session process exited"));
251
+ }
252
+ this.pendingControls.clear();
253
+ this.pendingApprovals.clear();
254
+ this.interruptRequested = false;
255
+ this.state.end(exit, this.process.stderr);
256
+ }
257
+ }
258
+ handleRecord(record, line) {
259
+ const type = readString(record, CLAUDE_SESSION.stream.field.type);
260
+ if (type === CLAUDE_SESSION.stream.type.assistant) {
261
+ for (const event of parseClaudeLine(line)) {
262
+ if (event.type === AGENT_EVENT_TYPE.message) {
263
+ this.state.message(event.text);
264
+ }
265
+ else if (event.type === AGENT_EVENT_TYPE.tool) {
266
+ this.state.emit({ type: SESSION_EVENT_TYPE.tool, name: event.name });
267
+ }
268
+ }
269
+ return;
270
+ }
271
+ if (type === CLAUDE_SESSION.stream.type.result) {
272
+ this.handleResult(record);
273
+ return;
274
+ }
275
+ if (type === CLAUDE_SESSION.stream.type.controlRequest) {
276
+ this.handleControlRequest(record);
277
+ return;
278
+ }
279
+ if (type === CLAUDE_SESSION.stream.type.controlResponse) {
280
+ this.handleControlResponse(record);
281
+ }
282
+ }
283
+ handleResult(record) {
284
+ const isError = record[CLAUDE_SESSION.stream.field.isError] === true;
285
+ const outcome = this.interruptRequested
286
+ ? TURN_OUTCOME.interrupted
287
+ : isError
288
+ ? TURN_OUTCOME.failed
289
+ : TURN_OUTCOME.completed;
290
+ this.interruptRequested = false;
291
+ // Approval requests belong to the turn that raised them.
292
+ this.pendingApprovals.clear();
293
+ this.state.endTurn(outcome, readString(record, CLAUDE_SESSION.stream.field.result));
294
+ }
295
+ handleControlRequest(record) {
296
+ const requestId = readString(record, CLAUDE_SESSION.stream.field.requestId);
297
+ const request = readRecord(record, CLAUDE_SESSION.stream.field.request);
298
+ const subtype = request === null
299
+ ? null
300
+ : readString(request, CLAUDE_SESSION.stream.field.subtype);
301
+ if (requestId === null || request === null) {
302
+ return;
303
+ }
304
+ if (subtype !== CLAUDE_SESSION.stream.subtype.canUseTool) {
305
+ this.ctx.logger.log("warn", `ignoring unsupported claude control request "${subtype ?? "?"}"`);
306
+ return;
307
+ }
308
+ this.pendingApprovals.set(requestId, request[CLAUDE_SESSION.stream.field.input]);
309
+ const toolName = readString(request, CLAUDE_SESSION.stream.field.toolName) ?? "";
310
+ const description = readString(request, CLAUDE_SESSION.stream.field.description);
311
+ this.state.emit({
312
+ type: SESSION_EVENT_TYPE.approvalRequested,
313
+ requestId,
314
+ summary: description === null ? toolName : `${toolName} ${description}`,
315
+ decisions: CLAUDE_APPROVAL_DECISIONS,
316
+ });
317
+ }
318
+ handleControlResponse(record) {
319
+ const response = readRecord(record, CLAUDE_SESSION.stream.field.response);
320
+ const requestId = response === null
321
+ ? null
322
+ : readString(response, CLAUDE_SESSION.stream.field.requestId);
323
+ if (response === null || requestId === null) {
324
+ return;
325
+ }
326
+ const pending = this.pendingControls.get(requestId);
327
+ if (pending === undefined) {
328
+ return;
329
+ }
330
+ this.pendingControls.delete(requestId);
331
+ pending.cancelTimeout();
332
+ if (readString(response, CLAUDE_SESSION.stream.field.subtype) ===
333
+ CLAUDE_SESSION.stream.subtype.error) {
334
+ pending.reject(sessionStateError(CLAUDE.id, redactSecrets(readString(response, CLAUDE_SESSION.stream.field.error) ??
335
+ "control request failed")));
336
+ return;
337
+ }
338
+ pending.resolve();
339
+ }
340
+ }
341
+ async function openClaudeSession(target, options, ctx, sessionOptions) {
342
+ const process = await SessionProcess.spawn({
343
+ command: CLAUDE.command,
344
+ args: sessionArgs(options.permissions, target, sessionOptions),
345
+ cwd: options.workspace,
346
+ ctx,
347
+ agentId: CLAUDE.id,
348
+ });
349
+ return new ClaudeSession(target.sessionId, process, ctx);
350
+ }
351
+ function claudeRoot(ctx, ...segments) {
352
+ return path.join(ctx.location.homeDir, CLAUDE_SESSION.files.root, ...segments);
353
+ }
354
+ /** `~/.claude/projects/<encoded cwd>/<id>.jsonl` — the project directory name is not derivable, so scan. */
355
+ async function findClaudeTranscript(ctx, id) {
356
+ const root = claudeRoot(ctx, CLAUDE_SESSION.files.projects);
357
+ for (const project of await readDirectoryNames(root)) {
358
+ const candidate = path.join(root, project, `${id}${CLAUDE_SESSION.files.transcriptExtension}`);
359
+ if (await fileExists(candidate)) {
360
+ return candidate;
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+ /** `~/.claude/sessions/<pid>.json` — one file per live CLI process, keyed by pid. */
366
+ async function claudeRegistryOwner(ctx, id) {
367
+ const root = claudeRoot(ctx, CLAUDE_SESSION.files.sessions);
368
+ for (const name of await readDirectoryNames(root)) {
369
+ if (!name.endsWith(CLAUDE_SESSION.files.registryExtension)) {
370
+ continue;
371
+ }
372
+ const entry = await readJsonFile(path.join(root, name));
373
+ if (entry === null ||
374
+ readString(entry, CLAUDE_SESSION.files.sessionId) !== id) {
375
+ continue;
376
+ }
377
+ return {
378
+ kind: SESSION_OWNER_KIND.process,
379
+ pid: readNumber(entry, CLAUDE_SESSION.files.pid),
380
+ entrypoint: readString(entry, CLAUDE_SESSION.files.entrypoint),
381
+ };
382
+ }
383
+ return { kind: SESSION_OWNER_KIND.none };
384
+ }
385
+ function permissionModeFrom(value) {
386
+ return value !== null &&
387
+ Object.values(CLAUDE_PERMISSION_MODE).includes(value)
388
+ ? value
389
+ : null;
390
+ }
391
+ async function inspectClaudeSession(id, ctx) {
392
+ assertHostSessionId(id, CLAUDE.id);
393
+ const transcript = await findClaudeTranscript(ctx, id);
394
+ if (transcript === null) {
395
+ return null;
396
+ }
397
+ const cwd = await scanJsonlFirstLast(transcript, (record) => readString(record, CLAUDE_SESSION.files.cwd));
398
+ if (cwd.first === null) {
399
+ return null;
400
+ }
401
+ const modes = await scanJsonlFirstLast(transcript, (record) => readString(record, CLAUDE_SESSION.files.permissionMode));
402
+ const mode = permissionModeFrom(modes.last);
403
+ return {
404
+ workspace: cwd.first,
405
+ lastPermissions: mode === null ? null : { mode },
406
+ owner: await claudeRegistryOwner(ctx, id),
407
+ archived: false,
408
+ };
409
+ }
410
+ export function claudeSessions(options) {
411
+ return {
412
+ open: (resolved, ctx) => openClaudeSession({ kind: "new", sessionId: randomUUID() }, resolved, ctx, options),
413
+ resume: async (id, resolved, ctx) => {
414
+ assertHostSessionId(id, CLAUDE.id);
415
+ return openClaudeSession({ kind: "resume", sessionId: id }, resolved, ctx, options);
416
+ },
417
+ inspect: inspectClaudeSession,
418
+ };
419
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Claude CLI stream-json vocabulary and line readers, shared by the
3
+ * one-shot run adapter (`claude.ts`) and the long-lived session adapter
4
+ * (`claude-session.ts`): one place spells the CLI's flags and the NDJSON
5
+ * field names both of them read.
6
+ */
7
+ import type { AgentEvent } from "../core/run.js";
8
+ import type { CliResultCapture } from "../kernel/cli-agent.js";
9
+ export declare const CLAUDE: {
10
+ readonly id: "claude";
11
+ readonly command: "claude";
12
+ readonly cli: {
13
+ readonly prompt: "-p";
14
+ readonly model: "--model";
15
+ readonly effort: "--effort";
16
+ readonly outputFormat: "--output-format";
17
+ readonly streamJson: "stream-json";
18
+ readonly permissionMode: "--permission-mode";
19
+ readonly bypassPermissions: "bypassPermissions";
20
+ readonly defaultPermissionMode: "default";
21
+ readonly verbose: "--verbose";
22
+ readonly tools: "--tools";
23
+ readonly allowedTools: "--allowedTools";
24
+ readonly safeMode: "--safe-mode";
25
+ readonly strictMcpConfig: "--strict-mcp-config";
26
+ readonly noSessionPersistence: "--no-session-persistence";
27
+ };
28
+ readonly auth: {
29
+ readonly statusArgs: readonly ["auth", "status", "--json"];
30
+ readonly loginArgs: readonly ["auth", "login", "--claudeai"];
31
+ };
32
+ readonly mcp: {
33
+ readonly addJson: "add-json";
34
+ readonly mcp: "mcp";
35
+ readonly remove: "remove";
36
+ readonly scope: "--scope";
37
+ };
38
+ readonly plugin: {
39
+ readonly plugin: "plugin";
40
+ readonly install: "install";
41
+ };
42
+ readonly json: {
43
+ readonly field: {
44
+ readonly type: "type";
45
+ readonly message: "message";
46
+ readonly content: "content";
47
+ readonly text: "text";
48
+ readonly name: "name";
49
+ readonly usage: "usage";
50
+ readonly result: "result";
51
+ readonly inputTokens: "input_tokens";
52
+ readonly outputTokens: "output_tokens";
53
+ readonly totalCost: "total_cost_usd";
54
+ };
55
+ readonly type: {
56
+ readonly assistant: "assistant";
57
+ readonly result: "result";
58
+ readonly text: "text";
59
+ readonly toolUse: "tool_use";
60
+ };
61
+ };
62
+ };
63
+ export declare function parseClaudeLine(line: string): AgentEvent[];
64
+ export declare function parseClaudeResult(line: string): CliResultCapture | null;
65
+ //# sourceMappingURL=claude-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-stream.d.ts","sourceRoot":"","sources":["../../src/agents/claude-stream.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,OAAO,KAAK,EAAE,UAAU,EAAY,MAAM,gBAAgB,CAAC;AAE3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqDT,CAAC;AAEX,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,EAAE,CAkC1D;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAkBvE"}