@alpacakit/agents 0.1.0-beta.37 → 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.
- package/README.md +121 -1
- package/dist/agents/claude-session.d.ts +37 -0
- package/dist/agents/claude-session.d.ts.map +1 -0
- package/dist/agents/claude-session.js +419 -0
- package/dist/agents/claude-stream.d.ts +65 -0
- package/dist/agents/claude-stream.d.ts.map +1 -0
- package/dist/agents/claude-stream.js +111 -0
- package/dist/agents/claude.d.ts +2 -1
- package/dist/agents/claude.d.ts.map +1 -1
- package/dist/agents/claude.js +6 -107
- package/dist/agents/codex-session.d.ts +52 -0
- package/dist/agents/codex-session.d.ts.map +1 -0
- package/dist/agents/codex-session.js +495 -0
- package/dist/agents/codex.d.ts +2 -1
- package/dist/agents/codex.d.ts.map +1 -1
- package/dist/agents/codex.js +3 -1
- package/dist/codex/app-server.d.ts +50 -1
- package/dist/codex/app-server.d.ts.map +1 -1
- package/dist/codex/app-server.js +130 -27
- package/dist/codex/index.d.ts +3 -2
- package/dist/codex/index.d.ts.map +1 -1
- package/dist/codex/index.js +2 -1
- package/dist/codex/protocol.d.ts +101 -0
- package/dist/codex/protocol.d.ts.map +1 -1
- package/dist/codex/protocol.js +78 -0
- package/dist/core/process-close.d.ts +11 -0
- package/dist/core/process-close.d.ts.map +1 -0
- package/dist/core/process-close.js +27 -0
- package/dist/core/session.d.ts +100 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +29 -0
- package/dist/host/session-files.d.ts +22 -0
- package/dist/host/session-files.d.ts.map +1 -0
- package/dist/host/session-files.js +91 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/internal/controlled-process/host-controller.d.ts.map +1 -1
- package/dist/internal/controlled-process/host-controller.js +23 -1
- package/dist/internal/controlled-process/supervisor-runtime.js +41 -2
- package/dist/kernel/definition.d.ts +11 -2
- package/dist/kernel/definition.d.ts.map +1 -1
- package/dist/kernel/facets.d.ts +38 -1
- package/dist/kernel/facets.d.ts.map +1 -1
- package/dist/kernel/facets.js +20 -0
- package/dist/kernel/runtime.d.ts +10 -6
- package/dist/kernel/runtime.d.ts.map +1 -1
- package/dist/kernel/runtime.js +4 -1
- package/dist/kernel/session.d.ts +97 -0
- package/dist/kernel/session.d.ts.map +1 -0
- package/dist/kernel/session.js +255 -0
- package/dist/testing/fake-exec.d.ts +6 -0
- package/dist/testing/fake-exec.d.ts.map +1 -1
- package/dist/testing/fake-exec.js +3 -1
- package/package.json +2 -2
- package/dist/auth/profile-store.d.ts +0 -14
- package/dist/auth/profile-store.d.ts.map +0 -1
- package/dist/auth/profile-store.js +0 -102
- package/dist/keyring/index.d.ts +0 -2
- package/dist/keyring/index.d.ts.map +0 -1
- package/dist/keyring/index.js +0 -1
- package/dist/keyring/resolver.d.ts +0 -9
- package/dist/keyring/resolver.d.ts.map +0 -1
- package/dist/keyring/resolver.js +0 -64
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"}
|