allaboard 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. 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
+ }