allaboard 0.0.0-stage → 0.4.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.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- package/slack-app-manifest.json +50 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** The Claude back end as the registry holds it: the entry `claude`. */
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { AgentConfigError } from "../seam.js";
|
|
5
|
+
import { ClaudeBackend } from "./backend.js";
|
|
6
|
+
// Claude Code's own variables the owner may set in the daemon's file; each reaches a thread's
|
|
7
|
+
// session as written, and Claude Code judges its value. `BASH_DEFAULT_TIMEOUT_MS`, in
|
|
8
|
+
// milliseconds, is how long a command may run: above 30 minutes it also replaces the limit a
|
|
9
|
+
// session of the SDK puts on a command in the background, which a terminal session does not
|
|
10
|
+
// have (tools reference, "Time limit for background commands", read 2026-10-13).
|
|
11
|
+
// `CLAUDE_CONFIG_DIR` is the folder of a login of its own, for an instance that drives a second
|
|
12
|
+
// Claude Code account (environment variables page, "useful for running multiple accounts side by
|
|
13
|
+
// side", read 2026-10-17); it is handled below, since the daemon reads the folder too.
|
|
14
|
+
export const VARIABLES = ["BASH_DEFAULT_TIMEOUT_MS", "CLAUDE_CONFIG_DIR"];
|
|
15
|
+
/**
|
|
16
|
+
* The declared variables the owner's file sets, each read by its name: the daemon hands a module
|
|
17
|
+
* its variables readable by lookup and shown by nothing, so a spread of them copies none.
|
|
18
|
+
*/
|
|
19
|
+
function set(variables) {
|
|
20
|
+
const found = {};
|
|
21
|
+
for (const name of VARIABLES) {
|
|
22
|
+
const value = variables[name];
|
|
23
|
+
if (value !== undefined)
|
|
24
|
+
found[name] = value;
|
|
25
|
+
}
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The folder the owner's file names, else the one the daemon's environment holds (an empty one is
|
|
30
|
+
* none), `~` read as the home and `${HOME}` already expanded by the daemon; undefined when neither
|
|
31
|
+
* names one. Refused unless absolute: a relative path would read from the working directory, the
|
|
32
|
+
* terminal's folder in one run and `/` under launchd. The value is passed on as written once the
|
|
33
|
+
* tilde is read: Claude Code keys the Keychain entry of a login by the folder's string, so a
|
|
34
|
+
* trailing slash is another entry. (The core's `expandUser` is not importable from an agent
|
|
35
|
+
* module, so the tilde is read here.)
|
|
36
|
+
*/
|
|
37
|
+
function configDirOf(written, home) {
|
|
38
|
+
const inherited = written === undefined ? process.env.CLAUDE_CONFIG_DIR || undefined : undefined;
|
|
39
|
+
const value = written ?? inherited;
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
// Concatenated, not joined: `join` would rewrite `~/./work` or a doubled slash, and the Keychain
|
|
43
|
+
// entry follows the string.
|
|
44
|
+
const expanded = value === "~" || value.startsWith("~/") ? home + value.slice(1) : value;
|
|
45
|
+
if (!isAbsolute(expanded)) {
|
|
46
|
+
const where = inherited === undefined ? "" : ", set in the daemon's environment and not in .env,";
|
|
47
|
+
throw new AgentConfigError(`CLAUDE_CONFIG_DIR${where} must be an absolute path, or one starting with ~/; it is "${value}"`);
|
|
48
|
+
}
|
|
49
|
+
return expanded;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The entry that builds the Claude back end. `options` are what a test replaces: the SDK's
|
|
53
|
+
* query, the owner's home, the usage probe's start.
|
|
54
|
+
*/
|
|
55
|
+
export function claudeAgent(options = {}) {
|
|
56
|
+
return {
|
|
57
|
+
required: [],
|
|
58
|
+
optional: VARIABLES,
|
|
59
|
+
build: (context) => {
|
|
60
|
+
const variables = set(context.variables);
|
|
61
|
+
const configDir = configDirOf(variables.CLAUDE_CONFIG_DIR, options.home ?? homedir());
|
|
62
|
+
if (configDir !== undefined) {
|
|
63
|
+
variables.CLAUDE_CONFIG_DIR = configDir;
|
|
64
|
+
// The SDK's `listSessions`, which the back end calls in the daemon's own process, has no
|
|
65
|
+
// option for the folder (`ListSessionsOptions`, sdk.d.ts 0.3.301) and reads
|
|
66
|
+
// `process.env.CLAUDE_CONFIG_DIR` (bundled sdk.mjs, not documented): without this, it
|
|
67
|
+
// would look in the default login's folder, and `!resume` and the Home tab's session
|
|
68
|
+
// list would come up empty. (The cleanup reads the back end's own projects folder and
|
|
69
|
+
// is not affected.) It is written once, at build. The usage probe's bare session passes
|
|
70
|
+
// no `env`, so the SDK gives it this environment, and the limits it reads are those of
|
|
71
|
+
// this login.
|
|
72
|
+
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
73
|
+
}
|
|
74
|
+
return new ClaudeBackend({
|
|
75
|
+
clock: context.clock,
|
|
76
|
+
variables,
|
|
77
|
+
...(configDir !== undefined && { configDir }),
|
|
78
|
+
...options,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function contentOf(prompt) {
|
|
2
|
+
if (typeof prompt.content === "string")
|
|
3
|
+
return prompt.content;
|
|
4
|
+
return prompt.content.map((part) => {
|
|
5
|
+
if (part.type === "text")
|
|
6
|
+
return { type: "text", text: part.text };
|
|
7
|
+
// The core admits the image types Claude takes and no other (`attachments.IMAGE_TYPES`),
|
|
8
|
+
// so the seam's plain string is one of the SDK's four here.
|
|
9
|
+
const media_type = part.mediaType;
|
|
10
|
+
return { type: "image", source: { type: "base64", media_type, data: part.data } };
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* `prompt` as the one user message of a turn, under its id: the message the SDK itself builds
|
|
15
|
+
* for a string, plus the uuid. The id is a UUID the core made.
|
|
16
|
+
*/
|
|
17
|
+
export function userMessage(prompt) {
|
|
18
|
+
return {
|
|
19
|
+
type: "user",
|
|
20
|
+
message: { role: "user", content: contentOf(prompt) },
|
|
21
|
+
parent_tool_use_id: null,
|
|
22
|
+
uuid: prompt.id,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code's record of the owner, `.claude.json`: in the folder `CLAUDE_CONFIG_DIR` names when
|
|
4
|
+
* the login has its own (measured 2026-10-17, CLI 2.1.301: with the variable set, the file is
|
|
5
|
+
* written inside that folder), else in the home.
|
|
6
|
+
*/
|
|
7
|
+
export function recordPath(home, configDir) {
|
|
8
|
+
return join(configDir ?? home, ".claude.json");
|
|
9
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { isRecord } from "../../values.js";
|
|
2
|
+
import { string, words } from "./wire.js";
|
|
3
|
+
export const QUESTION_TOOL = "AskUserQuestion";
|
|
4
|
+
function optionOf(value) {
|
|
5
|
+
if (!isRecord(value))
|
|
6
|
+
return null;
|
|
7
|
+
const label = string(value.label);
|
|
8
|
+
if (label === null)
|
|
9
|
+
return null;
|
|
10
|
+
return { label, description: words(value.description), preview: words(value.preview) };
|
|
11
|
+
}
|
|
12
|
+
function questionOf(value) {
|
|
13
|
+
if (!isRecord(value) || !Array.isArray(value.options))
|
|
14
|
+
return null;
|
|
15
|
+
const text = string(value.question);
|
|
16
|
+
if (text === null)
|
|
17
|
+
return null;
|
|
18
|
+
const options = [];
|
|
19
|
+
for (const entry of value.options) {
|
|
20
|
+
const option = optionOf(entry);
|
|
21
|
+
if (option === null)
|
|
22
|
+
return null;
|
|
23
|
+
options.push(option);
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
header: string(value.header) ?? "",
|
|
27
|
+
text,
|
|
28
|
+
multiSelect: value.multiSelect === true,
|
|
29
|
+
options,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The questions of an `AskUserQuestion` input, or null when it holds none or one the form
|
|
34
|
+
* cannot show (a shape other than the measured one): the call then asks as any other tool.
|
|
35
|
+
*/
|
|
36
|
+
function questionsOf(input) {
|
|
37
|
+
if (!Array.isArray(input.questions) || input.questions.length === 0)
|
|
38
|
+
return null;
|
|
39
|
+
const questions = [];
|
|
40
|
+
for (const entry of input.questions) {
|
|
41
|
+
const question = questionOf(entry);
|
|
42
|
+
if (question === null)
|
|
43
|
+
return null;
|
|
44
|
+
questions.push(question);
|
|
45
|
+
}
|
|
46
|
+
return questions;
|
|
47
|
+
}
|
|
48
|
+
/** One call of the permission callback as the request the core shows. */
|
|
49
|
+
export function toRequest(toolName, input, context) {
|
|
50
|
+
const callId = words(context.toolUseID);
|
|
51
|
+
const questions = toolName === QUESTION_TOOL ? questionsOf(input) : null;
|
|
52
|
+
if (questions !== null) {
|
|
53
|
+
return {
|
|
54
|
+
type: "question",
|
|
55
|
+
callId,
|
|
56
|
+
toolName,
|
|
57
|
+
title: words(context.title),
|
|
58
|
+
questions,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
type: "permission",
|
|
63
|
+
callId,
|
|
64
|
+
toolName,
|
|
65
|
+
input,
|
|
66
|
+
title: words(context.title),
|
|
67
|
+
description: words(context.description),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** The owner's answer to a permission request for a call that had `input`. */
|
|
71
|
+
export function permissionResult(answer, input) {
|
|
72
|
+
if (!answer.allow)
|
|
73
|
+
return { behavior: "deny", message: answer.message };
|
|
74
|
+
return { behavior: "allow", updatedInput: { ...input } };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The annotations of an answer, keyed by question text (`sdk-tools.d.ts` 0.3.301). The terminal
|
|
78
|
+
* sends the preview of the option picked with a single-select answer, and the model reads it
|
|
79
|
+
* back as `selected preview:` (measured 2026-10-16); an answer typed under Other matches no
|
|
80
|
+
* option and carries none.
|
|
81
|
+
*/
|
|
82
|
+
function annotationsOf(answer, questions) {
|
|
83
|
+
const found = {};
|
|
84
|
+
for (const entry of questions) {
|
|
85
|
+
const question = questionOf(entry);
|
|
86
|
+
if (question === null)
|
|
87
|
+
continue;
|
|
88
|
+
const annotation = {};
|
|
89
|
+
const note = Object.hasOwn(answer.notes, question.text) ? answer.notes[question.text] : "";
|
|
90
|
+
if (note)
|
|
91
|
+
annotation.notes = note;
|
|
92
|
+
const given = Object.hasOwn(answer.answers, question.text)
|
|
93
|
+
? answer.answers[question.text]
|
|
94
|
+
: null;
|
|
95
|
+
if (typeof given === "string" && !question.multiSelect) {
|
|
96
|
+
const preview = question.options.find((option) => option.label === given)?.preview;
|
|
97
|
+
if (preview)
|
|
98
|
+
annotation.preview = preview;
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(annotation).length > 0)
|
|
101
|
+
found[question.text] = annotation;
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The owner's answer to a question whose call had `input`: the questions as Claude Code wrote
|
|
107
|
+
* them, with the answer of each under its text, and the annotations when there are any.
|
|
108
|
+
*/
|
|
109
|
+
export function questionResult(answer, input) {
|
|
110
|
+
if (!answer.answered)
|
|
111
|
+
return { behavior: "deny", message: answer.message };
|
|
112
|
+
const questions = Array.isArray(input.questions) ? input.questions : [];
|
|
113
|
+
const annotations = annotationsOf(answer, questions);
|
|
114
|
+
return {
|
|
115
|
+
behavior: "allow",
|
|
116
|
+
updatedInput: {
|
|
117
|
+
questions,
|
|
118
|
+
answers: answer.answers,
|
|
119
|
+
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Claude Code session over a single `query()` with streaming input.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's `query()` starts one Claude Code process. Prompts reach it through an async iterable
|
|
5
|
+
* the session keeps open, so a prompt can be sent at any moment, in the middle of a turn too
|
|
6
|
+
* (`replay-user-messages` makes Claude Code say which prompts it took). Everything the process
|
|
7
|
+
* says comes back as the records of the query's stream, which a `Translator` turns into the
|
|
8
|
+
* seam's events, and as the calls of the permission callback and of the two hooks the daemon
|
|
9
|
+
* registers. A task of its own (`#pump`) reads that stream.
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { query as sdkQuery, } from "@anthropic-ai/claude-agent-sdk";
|
|
13
|
+
import { getLogger } from "../../log.js";
|
|
14
|
+
import { errorName, isRecord } from "../../values.js";
|
|
15
|
+
import { ResumeRefused, } from "../seam.js";
|
|
16
|
+
import { CAPABILITIES } from "./capabilities.js";
|
|
17
|
+
import { postModelSwitchHookEvents, postToolUseHookEvents, stopHookEvents } from "./hooks.js";
|
|
18
|
+
import { agentInfo, contextUsage as contextUsageOf } from "./info.js";
|
|
19
|
+
import { userMessage } from "./prompt.js";
|
|
20
|
+
import { permissionResult, questionResult, toRequest } from "./requests.js";
|
|
21
|
+
import { Translator } from "./translate.js";
|
|
22
|
+
export const logger = getLogger("allaboard.agent.claude.session");
|
|
23
|
+
// The reason given when the stream ends without an exception.
|
|
24
|
+
const PROCESS_EXITED = "the Claude Code process exited";
|
|
25
|
+
// How the SDK says the process ended, and the name each way is given (issue #238). It throws a
|
|
26
|
+
// plain `Error` for these, with no class and no code to tell them by, and its troubleshooting
|
|
27
|
+
// page says to match on the message (code.claude.com/docs/en/agent-sdk/troubleshooting, read
|
|
28
|
+
// 2026-10-11): a nonzero exit, a signal, and an error result the CLI reported before it exited.
|
|
29
|
+
// Only the start of the message is read; what follows is the CLI's own words.
|
|
30
|
+
const LOST_PROCESS = [
|
|
31
|
+
["Claude Code process exited with code ", "ProcessExited"],
|
|
32
|
+
["Claude Code process terminated by signal ", "ProcessKilled"],
|
|
33
|
+
["Claude Code returned an error result: ", "ErrorResult"],
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* The name of a failure of the SDK: `errorName`, except for an error that names itself no more
|
|
37
|
+
* than `Error` and whose message starts as one of `LOST_PROCESS`.
|
|
38
|
+
*/
|
|
39
|
+
function lostName(error) {
|
|
40
|
+
const name = errorName(error);
|
|
41
|
+
if (name !== "Error" || !(error instanceof Error))
|
|
42
|
+
return name;
|
|
43
|
+
return LOST_PROCESS.find(([start]) => error.message.startsWith(start))?.[1] ?? name;
|
|
44
|
+
}
|
|
45
|
+
/** A Claude Code process that ended, named for how it did; the SDK's own error is its cause. */
|
|
46
|
+
export class ProcessLostError extends Error {
|
|
47
|
+
constructor(name, cause) {
|
|
48
|
+
super("the Claude Code process ended", { cause });
|
|
49
|
+
this.name = name;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** `error` as it leaves this class: under its name when it says the process ended, else itself. */
|
|
53
|
+
function underItsName(error) {
|
|
54
|
+
const name = lostName(error);
|
|
55
|
+
return name === errorName(error) ? error : new ProcessLostError(name, error);
|
|
56
|
+
}
|
|
57
|
+
// What a call is told when nobody could be asked about it. This layer does not import
|
|
58
|
+
// `src/core/`, so the core's own text for it (`APPROVAL_UNPOSTED`) is not reused, and it knows
|
|
59
|
+
// no chat, so it names none.
|
|
60
|
+
const REQUEST_UNSHOWN = "allaboard could not show this request to the owner, so nobody approved it.";
|
|
61
|
+
/** A session was used after it ended: closed, or its process lost. */
|
|
62
|
+
export class SessionClosedError extends Error {
|
|
63
|
+
name = "SessionClosedError";
|
|
64
|
+
constructor() {
|
|
65
|
+
super("the Claude Code session is closed");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* An unbounded queue read as an async iterable by one consumer. `end` lets what was pushed be
|
|
70
|
+
* read first, then finishes; a `return` from the consumer (a `break`) leaves the queue as it is.
|
|
71
|
+
*/
|
|
72
|
+
export class AsyncQueue {
|
|
73
|
+
#items = [];
|
|
74
|
+
#ended = false;
|
|
75
|
+
#wake = null;
|
|
76
|
+
push(item) {
|
|
77
|
+
if (this.#ended)
|
|
78
|
+
return;
|
|
79
|
+
this.#items.push(item);
|
|
80
|
+
this.#signal();
|
|
81
|
+
}
|
|
82
|
+
end() {
|
|
83
|
+
this.#ended = true;
|
|
84
|
+
this.#signal();
|
|
85
|
+
}
|
|
86
|
+
#signal() {
|
|
87
|
+
const wake = this.#wake;
|
|
88
|
+
this.#wake = null;
|
|
89
|
+
wake?.();
|
|
90
|
+
}
|
|
91
|
+
[Symbol.asyncIterator]() {
|
|
92
|
+
return {
|
|
93
|
+
next: async () => {
|
|
94
|
+
while (this.#items.length === 0 && !this.#ended) {
|
|
95
|
+
await new Promise((resolve) => {
|
|
96
|
+
this.#wake = resolve;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const item = this.#items.shift();
|
|
100
|
+
if (item === undefined)
|
|
101
|
+
return { done: true, value: undefined };
|
|
102
|
+
return { done: false, value: item };
|
|
103
|
+
},
|
|
104
|
+
return: async () => ({ done: true, value: undefined }),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// The modes Claude Code takes (`PermissionMode` of the SDK). More than the three roles
|
|
109
|
+
// `CAPABILITIES` declares: the core hands back the mode a session started in, which the owner's
|
|
110
|
+
// own settings choose (`acceptEdits`, `plan`, `dontAsk`), when a bypass is switched off.
|
|
111
|
+
const PERMISSION_MODES = new Set([
|
|
112
|
+
"default",
|
|
113
|
+
"acceptEdits",
|
|
114
|
+
"bypassPermissions",
|
|
115
|
+
"plan",
|
|
116
|
+
"dontAsk",
|
|
117
|
+
"auto",
|
|
118
|
+
]);
|
|
119
|
+
// Claude Code's own system prompt, which a terminal session works under. With no `systemPrompt`
|
|
120
|
+
// the SDK sends a minimal one, "that covers tool calling but omits the rest of the `claude_code`
|
|
121
|
+
// preset's content" (Modifying system prompts, read 2026-10-13): 679 characters against about
|
|
122
|
+
// 10,000, measured the same day on the first request of both (Claude Code 2.1.296, issue #300).
|
|
123
|
+
// Claude Code records the prompt on a session's first request and reuses it until a compaction.
|
|
124
|
+
const SYSTEM_PROMPT = { type: "preset", preset: "claude_code" };
|
|
125
|
+
// What an interactive session has by default and a session of the SDK does not (sub-agents and
|
|
126
|
+
// environment variables pages, read 2026-10-13). Fork mode: every subagent runs in the
|
|
127
|
+
// background and the Agent tool loses `run_in_background`; off, Claude picks foreground or
|
|
128
|
+
// background, and picked each once in two identical runs. The refill of a session's web
|
|
129
|
+
// searches: 100 an hour in the terminal, none otherwise.
|
|
130
|
+
const TERMINAL_DEFAULTS = {
|
|
131
|
+
CLAUDE_CODE_FORK_SUBAGENT: "1",
|
|
132
|
+
CLAUDE_CODE_WEB_SEARCH_REFILLS_PER_HOUR: "100",
|
|
133
|
+
};
|
|
134
|
+
// What the daemon asks a session to say on its stream (environment variables page, read
|
|
135
|
+
// 2026-10-14): `session_state_changed` records, which carry whether Claude Code is running,
|
|
136
|
+
// idle or waiting for an answer. Off by default, in the terminal too.
|
|
137
|
+
const SESSION_SIGNALS = {
|
|
138
|
+
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
139
|
+
};
|
|
140
|
+
// A level outside what `CAPABILITIES` declares, or a mode Claude Code does not take, is refused
|
|
141
|
+
// (the seam's contract).
|
|
142
|
+
function effortOf(level) {
|
|
143
|
+
if (!CAPABILITIES.effortLevels.includes(level)) {
|
|
144
|
+
throw new RangeError(`unknown effort level: ${level}`);
|
|
145
|
+
}
|
|
146
|
+
return level;
|
|
147
|
+
}
|
|
148
|
+
function modeOf(mode) {
|
|
149
|
+
if (!PERMISSION_MODES.has(mode))
|
|
150
|
+
throw new RangeError(`unknown permission mode: ${mode}`);
|
|
151
|
+
return mode;
|
|
152
|
+
}
|
|
153
|
+
export class ClaudeSession {
|
|
154
|
+
events;
|
|
155
|
+
#config;
|
|
156
|
+
#translator = new Translator();
|
|
157
|
+
#queue = new AsyncQueue();
|
|
158
|
+
#input = new AsyncQueue();
|
|
159
|
+
#query;
|
|
160
|
+
#finished;
|
|
161
|
+
#closing = false;
|
|
162
|
+
#ended = false;
|
|
163
|
+
#started = false;
|
|
164
|
+
#command = null;
|
|
165
|
+
// An error result arrived before the process answered the start: a resume it refused.
|
|
166
|
+
#startRefused = false;
|
|
167
|
+
/** Spawns the Claude Code process: `ready` says when it answered. */
|
|
168
|
+
constructor(config, requests, queryFunction = sdkQuery) {
|
|
169
|
+
this.#config = config;
|
|
170
|
+
this.events = this.#queue;
|
|
171
|
+
this.#query = queryFunction({
|
|
172
|
+
prompt: this.#input,
|
|
173
|
+
options: this.#options(requests),
|
|
174
|
+
});
|
|
175
|
+
this.#finished = this.#pump();
|
|
176
|
+
}
|
|
177
|
+
#options(requests) {
|
|
178
|
+
const { folder, resume, model, effort, chrome, bare, variables } = this.#config;
|
|
179
|
+
// Makes bypass possible, not active: `setPermissionMode` switches it on the live session.
|
|
180
|
+
// `replay-user-messages` (CLI reference): Claude Code re-emits each prompt with the uuid the
|
|
181
|
+
// daemon sent it under, which tells a prompt it took into a running turn from one that waits
|
|
182
|
+
// for a turn of its own. The owner's "Enabled by default" reaches an interactive session
|
|
183
|
+
// only: here `--chrome` is what connects Claude Code's Chrome integration.
|
|
184
|
+
const extraArgs = { "replay-user-messages": null };
|
|
185
|
+
if (chrome)
|
|
186
|
+
extraArgs.chrome = null;
|
|
187
|
+
return {
|
|
188
|
+
cwd: folder,
|
|
189
|
+
...(resume !== null && { resume }),
|
|
190
|
+
// The effort `/effort` set does not survive a resume (measured 2026-09-28), so the daemon
|
|
191
|
+
// keeps it per thread and passes it back here.
|
|
192
|
+
...(effort !== null && { effort: effortOf(effort) }),
|
|
193
|
+
...(model !== null && { model }),
|
|
194
|
+
settingSources: [...this.#config.settingsSources],
|
|
195
|
+
includePartialMessages: true,
|
|
196
|
+
...(!bare && {
|
|
197
|
+
allowDangerouslySkipPermissions: true,
|
|
198
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
199
|
+
// `env` replaces the environment the process would inherit, so the daemon's own is
|
|
200
|
+
// passed on: over the terminal's defaults, and under what the owner's file sets. What
|
|
201
|
+
// the daemon reads from the stream is not the owner's to turn off.
|
|
202
|
+
env: { ...TERMINAL_DEFAULTS, ...process.env, ...variables, ...SESSION_SIGNALS },
|
|
203
|
+
}),
|
|
204
|
+
extraArgs,
|
|
205
|
+
canUseTool: async (toolName, input, context) => {
|
|
206
|
+
// A callback that rejects is answered with a `control_response` of subtype `error`
|
|
207
|
+
// (`handleControlRequest`, SDK 0.3.296), which is not a deny. A request nobody could be
|
|
208
|
+
// asked about is refused here, and only the error's name is kept: its message may quote
|
|
209
|
+
// the tool's input.
|
|
210
|
+
try {
|
|
211
|
+
const request = toRequest(toolName, input, context);
|
|
212
|
+
if (request.type === "question") {
|
|
213
|
+
return questionResult(await requests.question(request, context.signal), input);
|
|
214
|
+
}
|
|
215
|
+
return permissionResult(await requests.permission(request, context.signal), input);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
logger.error(`could not ask the owner about a call: ${errorName(error)}`);
|
|
219
|
+
return { behavior: "deny", message: REQUEST_UNSHOWN };
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
// The Stop hook's input carries the effort level Claude Code runs at: the footer's only
|
|
223
|
+
// source for it, since no message reports it. Every hook input carries the `cwd` the
|
|
224
|
+
// session works in; PostToolUse reports it after each tool, so a turn stopped or failed
|
|
225
|
+
// before its Stop still moves the footer's branch. PostModelSwitch names the model a
|
|
226
|
+
// `/model` command switched to, before the command's result: the context usage still
|
|
227
|
+
// names the old one after a command.
|
|
228
|
+
hooks: {
|
|
229
|
+
Stop: [{ hooks: [this.#hook(stopHookEvents)] }],
|
|
230
|
+
PostToolUse: [{ hooks: [this.#hook(postToolUseHookEvents)] }],
|
|
231
|
+
PostModelSwitch: [{ hooks: [this.#hook(postModelSwitchHookEvents)] }],
|
|
232
|
+
},
|
|
233
|
+
// The process's stderr may quote the conversation: only its size is kept.
|
|
234
|
+
stderr: (line) => logger.debug(`claude stderr: ${line.length} chars`),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
#hook(events) {
|
|
238
|
+
return async (input) => {
|
|
239
|
+
// A hook of the hidden command's turn (Stop, PostToolUse) is as much the command's as its
|
|
240
|
+
// records: nothing of it reaches the core.
|
|
241
|
+
if (this.#command !== null)
|
|
242
|
+
return {};
|
|
243
|
+
for (const event of events(input))
|
|
244
|
+
this.#queue.push(event);
|
|
245
|
+
return {};
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Reads a record of the hidden model command: none reaches the core. The `result` settles the
|
|
250
|
+
* wait. The command's own `completed` settles it too (a no-op after the result) and ends the
|
|
251
|
+
* hiding; so does its own `cancelled`, the other terminal state `sdk.d.ts` documents (0.3.301,
|
|
252
|
+
* the comment of `cancel_queued`: "a terminal 'cancelled' lifecycle"), which fails a caller that had no result.
|
|
253
|
+
*/
|
|
254
|
+
#readCommand(command, record) {
|
|
255
|
+
if (!isRecord(record))
|
|
256
|
+
return;
|
|
257
|
+
if (record.type === "result")
|
|
258
|
+
command.settle();
|
|
259
|
+
if (record.type !== "command_lifecycle" || record.command_uuid !== command.id)
|
|
260
|
+
return;
|
|
261
|
+
if (record.state === "completed") {
|
|
262
|
+
command.settle();
|
|
263
|
+
this.#command = null;
|
|
264
|
+
}
|
|
265
|
+
else if (record.state === "cancelled") {
|
|
266
|
+
command.settle(new Error("the model command was cancelled"));
|
|
267
|
+
this.#command = null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Reads the query's stream until it ends, whatever ends it; never rejects. */
|
|
271
|
+
async #pump() {
|
|
272
|
+
let reason = PROCESS_EXITED;
|
|
273
|
+
try {
|
|
274
|
+
for await (const record of this.#query) {
|
|
275
|
+
if (!this.#started && isRecord(record) && record.type === "result") {
|
|
276
|
+
this.#startRefused = record.is_error === true;
|
|
277
|
+
}
|
|
278
|
+
if (this.#command !== null) {
|
|
279
|
+
this.#readCommand(this.#command, record);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
for (const event of this.#translator.translate(record))
|
|
284
|
+
this.#queue.push(event);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
// One record that fails to read must not end the session.
|
|
288
|
+
logger.error(`could not read a record: ${errorName(error)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
reason = lostName(error);
|
|
294
|
+
}
|
|
295
|
+
this.#ended = true;
|
|
296
|
+
this.#command?.settle(new SessionClosedError());
|
|
297
|
+
if (!this.#closing) {
|
|
298
|
+
logger.error(`session stopped: ${reason}`);
|
|
299
|
+
this.#queue.push({ type: "process_lost", reason });
|
|
300
|
+
}
|
|
301
|
+
this.#queue.end();
|
|
302
|
+
// The process may still be running (a stream that failed): reap it.
|
|
303
|
+
try {
|
|
304
|
+
this.#query.close();
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
logger.error(`could not close the process: ${errorName(error)}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Resolves once Claude Code answered the start. Rejects with `ResumeRefused` when it refused to
|
|
312
|
+
* resume the stored session, and with the failure itself otherwise; the session is closed then.
|
|
313
|
+
*/
|
|
314
|
+
async ready() {
|
|
315
|
+
try {
|
|
316
|
+
await this.#query.initializationResult();
|
|
317
|
+
this.#started = true;
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
// The stream is read to its end first: the error result it carries tells a refused resume
|
|
321
|
+
// from a process that failed to start.
|
|
322
|
+
await this.close();
|
|
323
|
+
if (this.#config.resume !== null && this.#startRefused)
|
|
324
|
+
throw new ResumeRefused({ cause: error });
|
|
325
|
+
throw underItsName(error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Asks the query. The SDK rejects a request with the error that ended the process, when it
|
|
330
|
+
* did: that one leaves under its name, as the stream's own end does.
|
|
331
|
+
*/
|
|
332
|
+
async #ask(request) {
|
|
333
|
+
try {
|
|
334
|
+
return await request();
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
throw underItsName(error);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async send(prompt) {
|
|
341
|
+
if (this.#closing || this.#ended)
|
|
342
|
+
throw new SessionClosedError();
|
|
343
|
+
// Before the push: the replay can come back before this call returns.
|
|
344
|
+
this.#translator.promptSent(prompt.id);
|
|
345
|
+
this.#input.push(userMessage(prompt));
|
|
346
|
+
}
|
|
347
|
+
async interrupt() {
|
|
348
|
+
// An interrupt while the model command runs rejects `setModel` and closes the session (the
|
|
349
|
+
// seam's contract): what the command's turn would still write is never read for a state.
|
|
350
|
+
const command = this.#command;
|
|
351
|
+
if (command !== null && !command.settled) {
|
|
352
|
+
command.settle(new Error("the model command was interrupted"));
|
|
353
|
+
await this.close();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await this.#ask(() => this.#query.interrupt());
|
|
357
|
+
}
|
|
358
|
+
async stopTask(taskId) {
|
|
359
|
+
await this.#ask(() => this.#query.stopTask(taskId));
|
|
360
|
+
}
|
|
361
|
+
async setPermissionMode(mode) {
|
|
362
|
+
await this.#ask(() => this.#query.setPermissionMode(modeOf(mode)));
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Runs Claude Code's own `/model` on the idle session and resolves once its turn is over; the
|
|
366
|
+
* turn is read here and no event of it reaches the core. The SDK's `setModel` is not used: it
|
|
367
|
+
* leaves an override that a later `/model` to the default model's alias does not undo
|
|
368
|
+
* (measured 2026-10-16, SDK 0.3.301, issue #333). A model the initialization result does not
|
|
369
|
+
* list is refused, since Claude Code answers an unknown one as a success.
|
|
370
|
+
*/
|
|
371
|
+
async setModel(model) {
|
|
372
|
+
if (this.#closing || this.#ended)
|
|
373
|
+
throw new SessionClosedError();
|
|
374
|
+
if (model !== null && !(await this.info()).models.some((entry) => entry.value === model)) {
|
|
375
|
+
throw new RangeError(`unknown model: ${model}`);
|
|
376
|
+
}
|
|
377
|
+
// The session may have ended during the await above, and `#pump` found no command to end.
|
|
378
|
+
if (this.#closing || this.#ended)
|
|
379
|
+
throw new SessionClosedError();
|
|
380
|
+
// The guard and the marker follow each other with no await between them: two callers cannot
|
|
381
|
+
// both pass. A second command is refused, not queued: the setup lock already serializes its
|
|
382
|
+
// one caller, so a second is a defect. The hidden tail of one that has settled does not count.
|
|
383
|
+
if (this.#command !== null && !this.#command.settled) {
|
|
384
|
+
throw new Error("a model command is already running");
|
|
385
|
+
}
|
|
386
|
+
const id = randomUUID();
|
|
387
|
+
const done = new Promise((resolve, reject) => {
|
|
388
|
+
const command = {
|
|
389
|
+
id,
|
|
390
|
+
settled: false,
|
|
391
|
+
settle: (error) => {
|
|
392
|
+
if (command.settled)
|
|
393
|
+
return;
|
|
394
|
+
command.settled = true;
|
|
395
|
+
if (error === undefined)
|
|
396
|
+
resolve();
|
|
397
|
+
else
|
|
398
|
+
reject(error);
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
this.#command = command;
|
|
402
|
+
});
|
|
403
|
+
this.#input.push(userMessage({ id, content: `/model ${model ?? "default"}` }));
|
|
404
|
+
await done;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Live, with no reconnect (`applyFlagSettings`, SDK 0.3.296): 36 ms in the spike's measurement,
|
|
408
|
+
* 28 ms in the back end's own live check, both on 2026-10-10.
|
|
409
|
+
*/
|
|
410
|
+
async setEffort(level) {
|
|
411
|
+
const effortLevel = level === null ? null : effortOf(level);
|
|
412
|
+
await this.#ask(() => this.#query.applyFlagSettings({ effortLevel }));
|
|
413
|
+
}
|
|
414
|
+
async contextUsage() {
|
|
415
|
+
return contextUsageOf(await this.#ask(() => this.#query.getContextUsage()));
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The initialization result holds the models, the commands with their aliases and the mode the
|
|
419
|
+
* session started in (measured 2026-10-10 on SDK 0.3.296): nothing else is asked.
|
|
420
|
+
*/
|
|
421
|
+
async info() {
|
|
422
|
+
return agentInfo(await this.#ask(() => this.#query.initializationResult()));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Ends the input, closes the query and resolves once its stream has ended, so that a new
|
|
426
|
+
* session may resume the same id right after (the process needs real time to flush).
|
|
427
|
+
*/
|
|
428
|
+
async close() {
|
|
429
|
+
if (!this.#closing) {
|
|
430
|
+
this.#closing = true;
|
|
431
|
+
this.#input.end();
|
|
432
|
+
try {
|
|
433
|
+
this.#query.close();
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
logger.error(`could not close the process: ${errorName(error)}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
await this.#finished;
|
|
440
|
+
}
|
|
441
|
+
}
|