@kammcs/buildit-mcp 0.2.0 → 0.8.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 (43) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +273 -32
  3. package/SECURITY.md +1 -1
  4. package/dist/api/client.js +5 -1
  5. package/dist/api/generated/operations.js +840 -11
  6. package/dist/api/generated/schemas.js +936 -3
  7. package/dist/api/generated/strict.js +1088 -3
  8. package/dist/capabilities.js +236 -0
  9. package/dist/channel/api.js +159 -0
  10. package/dist/channel/approvals.js +259 -0
  11. package/dist/channel/handoffs.js +190 -0
  12. package/dist/channel/item-events.js +164 -0
  13. package/dist/channel/lock.js +181 -0
  14. package/dist/channel/loop.js +1034 -0
  15. package/dist/channel/mask.js +365 -0
  16. package/dist/channel/threads.js +60 -0
  17. package/dist/channel/tool-ring.js +124 -0
  18. package/dist/channel/typing.js +150 -0
  19. package/dist/channel/waiters.js +229 -0
  20. package/dist/channel/workers.js +66 -0
  21. package/dist/cli/claude-config.js +100 -0
  22. package/dist/cli/commands.js +438 -0
  23. package/dist/config.js +356 -6
  24. package/dist/errors.js +49 -1
  25. package/dist/index.js +122 -6
  26. package/dist/log.js +3 -2
  27. package/dist/secrets/keychain.js +273 -0
  28. package/dist/server.js +61 -5
  29. package/dist/tools/approve.js +791 -0
  30. package/dist/tools/ask.js +898 -0
  31. package/dist/tools/chat.js +98 -1
  32. package/dist/tools/handoff.js +409 -0
  33. package/dist/tools/items-read.js +6 -3
  34. package/dist/tools/items-write.js +77 -16
  35. package/dist/tools/plans.js +221 -14
  36. package/dist/tools/reply.js +138 -0
  37. package/dist/tools/shared.js +24 -1
  38. package/dist/tools/threads.js +149 -0
  39. package/dist/tools/whoami.js +53 -3
  40. package/dist/toolsets/catalog.js +3 -1
  41. package/dist/toolsets/registry.js +2 -0
  42. package/dist/transports/stdio.js +288 -12
  43. package/package.json +5 -1
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Capability tags: what this computer can do for work that has Needs
3
+ * (macos, ios, flutter...), suggested to buildIt.Social at connect and when
4
+ * a channel session starts. The owner confirms the tags that count, in the
5
+ * app; a session only ever suggests.
6
+ *
7
+ * Detection is cheap and never fails: the operating system from
8
+ * process.platform, and toolchains by looking for known programs on the
9
+ * PATH, or for a known folder, without running anything. Every probe has a
10
+ * time limit, and a probe that fails or runs out of time means "not found".
11
+ * Only tag names from the fixed list below ever leave the machine: never a
12
+ * path, a version or any other detail of what was found.
13
+ */
14
+ import { constants } from 'node:fs';
15
+ import { access, readlink, stat } from 'node:fs/promises';
16
+ import { posix, win32 } from 'node:path';
17
+ /** The suggested tags (the app keeps the same list). Tags stay free-form. */
18
+ export const SUGGESTED_CAPABILITIES = [
19
+ 'windows',
20
+ 'macos',
21
+ 'linux',
22
+ 'ios',
23
+ 'android',
24
+ 'xcode',
25
+ 'flutter',
26
+ 'docker',
27
+ 'rust',
28
+ 'web',
29
+ 'gpu',
30
+ ];
31
+ /** A tag once normalised: lower case, a letter or digit, then up to 31 of a-z 0-9 + . _ - */
32
+ export const CAPABILITY_RE = /^[a-z0-9][a-z0-9+._-]{0,31}$/;
33
+ /** The most tags a session suggests (the server's limit). */
34
+ export const MAX_SESSION_CAPABILITIES = 20;
35
+ /** The most Needs an item has (the server's limit). */
36
+ export const MAX_ITEM_NEEDS = 10;
37
+ /** How long one probe may take before it counts as "not found". */
38
+ export const PROBE_TIMEOUT_MS = 1500;
39
+ /**
40
+ * Tags as the server stores them: trimmed, lower case, unique and sorted.
41
+ * Throws with the bad ones named, so a person's --capabilities can be fixed.
42
+ */
43
+ export function normalizeCapabilities(raw, max) {
44
+ const tags = [...new Set(raw.map((t) => t.trim().toLowerCase()).filter((t) => t !== ''))];
45
+ const bad = tags.filter((t) => !CAPABILITY_RE.test(t));
46
+ if (bad.length > 0) {
47
+ throw new Error(`Not a capability tag: ${bad.map((b) => JSON.stringify(b.slice(0, 40))).join(', ')}. A tag is a lower-case letter or digit, then up to 31 lower-case letters, digits or + . _ - (for example macos, ios, flutter).`);
48
+ }
49
+ if (tags.length > max)
50
+ throw new Error(`At most ${max} capability tags.`);
51
+ return tags.sort();
52
+ }
53
+ /** The real machine. */
54
+ export const NODE_HOST = {
55
+ platform: process.platform,
56
+ env: process.env,
57
+ async isExecutable(path) {
58
+ try {
59
+ const s = await stat(path);
60
+ if (!s.isFile())
61
+ return false;
62
+ if (process.platform === 'win32')
63
+ return true;
64
+ await access(path, constants.X_OK);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ },
71
+ async isDirectory(path) {
72
+ try {
73
+ return (await stat(path)).isDirectory();
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ },
79
+ async readLink(path) {
80
+ try {
81
+ return await readlink(path);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ },
87
+ };
88
+ /** The program names to look for on the PATH, by tag (android: hasAndroid). */
89
+ const PROGRAMS = [
90
+ { tag: 'flutter', programs: ['flutter'] },
91
+ { tag: 'docker', programs: ['docker'] },
92
+ { tag: 'rust', programs: ['cargo', 'rustc'] },
93
+ ];
94
+ /** Resolves to false when `probe` fails or takes longer than the time limit. */
95
+ async function within(probe, ms) {
96
+ let timer;
97
+ const late = new Promise((resolve) => {
98
+ timer = setTimeout(() => {
99
+ resolve(false);
100
+ }, ms);
101
+ });
102
+ try {
103
+ return await Promise.race([probe().catch(() => false), late]);
104
+ }
105
+ finally {
106
+ clearTimeout(timer);
107
+ }
108
+ }
109
+ /** Whether one of `names` is a program in a folder of the PATH. */
110
+ async function onPath(host, names) {
111
+ const win = host.platform === 'win32';
112
+ const path = (win ? (host.env.Path ?? host.env.PATH) : host.env.PATH) ?? '';
113
+ const paths = win ? win32 : posix;
114
+ const dirs = path
115
+ .split(win ? ';' : ':')
116
+ .map((d) => d.trim().replace(/^"(.*)"$/, '$1'))
117
+ .filter((d) => d !== '');
118
+ const extensions = win
119
+ ? (host.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
120
+ .split(';')
121
+ .map((e) => e.trim().toLowerCase())
122
+ .filter((e) => e !== '')
123
+ : [''];
124
+ const candidates = dirs.flatMap((dir) => names.flatMap((name) => extensions.map((ext) => paths.join(dir, name + ext))));
125
+ const found = await Promise.all(candidates.map((c) => host.isExecutable(c)));
126
+ return found.some(Boolean);
127
+ }
128
+ /** The Android SDK: ANDROID_HOME or ANDROID_SDK_ROOT names a folder, or adb is on the PATH. */
129
+ async function hasAndroid(host) {
130
+ for (const name of ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) {
131
+ const dir = host.env[name]?.trim();
132
+ if (dir !== undefined && dir !== '' && (await host.isDirectory(dir)))
133
+ return true;
134
+ }
135
+ return onPath(host, ['adb']);
136
+ }
137
+ /**
138
+ * The developer folder of Xcode (macOS), when it is the full app and not
139
+ * only the command line tools: DEVELOPER_DIR, else the folder xcode-select
140
+ * chose (the link it keeps), else the app in /Applications. Read from the
141
+ * file system; nothing is run.
142
+ */
143
+ async function xcodeDeveloperDir(host) {
144
+ const isApp = (dir) => /\.app\/Contents\/Developer\/?$/.test(dir);
145
+ const fromEnv = host.env.DEVELOPER_DIR?.trim();
146
+ if (fromEnv !== undefined && fromEnv !== '') {
147
+ return isApp(fromEnv) && (await host.isDirectory(fromEnv)) ? fromEnv : undefined;
148
+ }
149
+ const selected = await host.readLink('/var/db/xcode_select_link');
150
+ if (selected !== undefined) {
151
+ return isApp(selected) && (await host.isDirectory(selected)) ? selected : undefined;
152
+ }
153
+ const standard = '/Applications/Xcode.app/Contents/Developer';
154
+ return (await host.isDirectory(standard)) ? standard : undefined;
155
+ }
156
+ /** The operating system's tag, or undefined for one outside the list. */
157
+ export function osCapability(platform) {
158
+ switch (platform) {
159
+ case 'win32':
160
+ return 'windows';
161
+ case 'darwin':
162
+ return 'macos';
163
+ case 'linux':
164
+ return 'linux';
165
+ default:
166
+ return undefined;
167
+ }
168
+ }
169
+ /**
170
+ * The tags this machine suggests. Never throws: a probe that fails, or runs
171
+ * out of time, leaves its tag out. `web` and `gpu` are never detected (the
172
+ * owner adds them in the app).
173
+ */
174
+ export async function detectCapabilities(host = NODE_HOST) {
175
+ const ms = host.timeoutMs ?? PROBE_TIMEOUT_MS;
176
+ const tags = new Set();
177
+ try {
178
+ const os = osCapability(host.platform);
179
+ if (os !== undefined)
180
+ tags.add(os);
181
+ const probes = PROGRAMS.map(async ({ tag, programs }) => {
182
+ if (await within(() => onPath(host, programs), ms))
183
+ tags.add(tag);
184
+ });
185
+ probes.push((async () => {
186
+ if (await within(() => hasAndroid(host), ms))
187
+ tags.add('android');
188
+ })());
189
+ if (host.platform === 'darwin') {
190
+ probes.push((async () => {
191
+ let dir;
192
+ const found = await within(async () => {
193
+ dir = await xcodeDeveloperDir(host);
194
+ return dir !== undefined;
195
+ }, ms);
196
+ if (!found || dir === undefined)
197
+ return;
198
+ tags.add('xcode');
199
+ const sdk = posix.join(dir, 'Platforms', 'iPhoneOS.platform');
200
+ if (await within(() => host.isDirectory(sdk), ms))
201
+ tags.add('ios');
202
+ })());
203
+ }
204
+ await Promise.all(probes);
205
+ }
206
+ catch {
207
+ // Detection never fails connect: what was found so far is suggested.
208
+ }
209
+ // Only tags of the fixed list leave the machine.
210
+ return [...tags].filter((t) => SUGGESTED_CAPABILITIES.includes(t)).sort();
211
+ }
212
+ /**
213
+ * The tags to suggest: `override` (--capabilities, or BUILDIT_CAPABILITIES)
214
+ * replaces detection; "none" suggests none.
215
+ */
216
+ export async function suggestedCapabilities(override, host = NODE_HOST) {
217
+ if (override !== undefined)
218
+ return [...override];
219
+ return detectCapabilities(host);
220
+ }
221
+ /**
222
+ * --capabilities or BUILDIT_CAPABILITIES: tags separated by commas or
223
+ * spaces, or "none". Throws with what is wrong.
224
+ */
225
+ export function parseCapabilitiesOption(raw) {
226
+ const parts = raw
227
+ .split(/[\s,]+/)
228
+ .map((t) => t.trim())
229
+ .filter((t) => t !== '');
230
+ if (parts.length === 1 && parts[0]?.toLowerCase() === 'none')
231
+ return [];
232
+ if (parts.length === 0) {
233
+ throw new Error('Give capability tags separated by commas (for example macos,ios), or none.');
234
+ }
235
+ return normalizeCapabilities(parts, MAX_SESSION_CAPABILITIES);
236
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The agent API's session operations, typed from the contract: the
3
+ * device-code connect flow (`connect_start`, `connect_poll`, which take no
4
+ * token) and a Claude Code session's own conversation with its owner
5
+ * (`get_session`, `list_session_events`, `ack_session_events`,
6
+ * `post_session_message`, `session_heartbeat`, which take a session token),
7
+ * and its tool approvals (`post_session_approval`, `get_session_approval`,
8
+ * `close_session_approval`), its capability tags (`set_session_capabilities`) and
9
+ * its handoffs (`propose_session_handoff`, `get_session_handoff`,
10
+ * `close_session_handoff`, `decline_session_handoff`).
11
+ *
12
+ * Each goes through callOperation, like the tools' calls: request ids,
13
+ * timeouts, the error envelope, the one 429 retry, and tolerant parsing with
14
+ * the generated schemas.
15
+ */
16
+ import { z } from 'zod';
17
+ import { OPERATIONS } from '../api/generated/operations.js';
18
+ import { SessionEventSchema, } from '../api/generated/schemas.js';
19
+ import { callOperation } from '../api/operations.js';
20
+ /** The codes connect_poll answers while the person hasn't approved (RFC 8628, in the API's envelope). */
21
+ export const POLL_CODES = {
22
+ pending: 'authorization_pending',
23
+ slowDown: 'slow_down',
24
+ denied: 'access_denied',
25
+ expired: 'expired_token',
26
+ };
27
+ /** Codes that end a session for good: the token is no longer accepted. */
28
+ export const SESSION_ENDED_CODES = new Set(['token_revoked', 'token_expired']);
29
+ /** POST /v1/connect/start (no token). */
30
+ export function connectStart(api, body, options = {}) {
31
+ return callOperation(api, 'connect_start', { body }, options);
32
+ }
33
+ /** POST /v1/connect/poll (no token). The codes of POLL_CODES arrive as ApiErrors. */
34
+ export function connectPoll(api, deviceCode, options = {}) {
35
+ return callOperation(api, 'connect_poll', { body: { device_code: deviceCode } }, options);
36
+ }
37
+ /** GET /v1/session */
38
+ export function getSession(api, options = {}) {
39
+ return callOperation(api, 'get_session', {}, options);
40
+ }
41
+ /** A page of events, each checked on its own (see listSessionEvents). */
42
+ const EventsPageSchema = z.looseObject({
43
+ events: z.array(z.unknown()),
44
+ cursor: z.number(),
45
+ more: z.boolean(),
46
+ });
47
+ /**
48
+ * GET /v1/session/events. The contract may add event types, so the page is
49
+ * read with each event checked on its own: one this version doesn't know
50
+ * (or can't read) is left out, and `skipped` counts it. The cursor still
51
+ * covers it, so it is acknowledged with the page.
52
+ */
53
+ export async function listSessionEvents(api, query, options = {}) {
54
+ const op = OPERATIONS.list_session_events;
55
+ if (api.served?.operations !== undefined && !api.served.operations.has('list_session_events')) {
56
+ // callOperation's answer for an operation the deployment doesn't serve.
57
+ return callOperation(api, 'list_session_events', { query }, options).then((page) => ({
58
+ ...page,
59
+ skipped: 0,
60
+ }));
61
+ }
62
+ const page = await api.requestParsed(op.method, op.path, EventsPageSchema, {
63
+ ...options,
64
+ operation: 'list_session_events',
65
+ pathTemplate: op.path,
66
+ query: query,
67
+ });
68
+ const events = [];
69
+ for (const raw of page.events) {
70
+ const parsed = SessionEventSchema.safeParse(raw);
71
+ if (parsed.success)
72
+ events.push(parsed.data);
73
+ }
74
+ return { ...page, events, skipped: page.events.length - events.length };
75
+ }
76
+ /** POST /v1/session/events/ack */
77
+ export function ackSessionEvents(api, cursor, options = {}) {
78
+ return callOperation(api, 'ack_session_events', { body: { cursor } }, options);
79
+ }
80
+ /** POST /v1/session/messages, in the agent's own direct message with its owner. */
81
+ export function postSessionMessage(api, body, options = {}) {
82
+ return callOperation(api, 'post_session_message', { body }, options);
83
+ }
84
+ /** DELETE /v1/session: the session ends itself (its token stops working at once). 204, no body. */
85
+ export async function revokeSession(api, options = {}) {
86
+ await callOperation(api, 'revoke_session', {}, options);
87
+ }
88
+ /** POST /v1/session/heartbeat */
89
+ export function sessionHeartbeat(api, state, options = {}) {
90
+ return callOperation(api, 'session_heartbeat', { body: { state } }, options);
91
+ }
92
+ /** PATCH /v1/session/messages/{id}: changes the session's own message (its text, or its card). */
93
+ export function updateSessionMessage(api, messageId, body, options = {}) {
94
+ return callOperation(api, 'update_session_message', { path: { id: messageId }, body }, options);
95
+ }
96
+ /** POST /v1/session/messages/{id}/card/close: closes the session's own card to answers. */
97
+ export function closeSessionCard(api, messageId, options = {}) {
98
+ return callOperation(api, 'close_session_card', { path: { id: messageId }, body: {} }, options);
99
+ }
100
+ /** PATCH /v1/session/responses/{id}: accepts or rejects an answer to the session's own card. */
101
+ export function answerSessionResponse(api, responseId, body, options = {}) {
102
+ return callOperation(api, 'answer_session_response', { path: { id: responseId }, body }, options);
103
+ }
104
+ /** POST /v1/session/plan-cards: posts a plan's preview as a card the owner applies or cancels. */
105
+ export function postPlanCard(api, body, options = {}) {
106
+ return callOperation(api, 'post_plan_card', { body }, options);
107
+ }
108
+ /** POST /v1/session/typing: "<agent> is writing…" in a thread (204, no body). */
109
+ export async function sessionTyping(api, body, options = {}) {
110
+ await callOperation(api, 'session_typing', { body }, options);
111
+ }
112
+ /** GET /v1/session/threads: the session's threads, newest activity first (at most 50). */
113
+ export function listSessionThreads(api, options = {}) {
114
+ return callOperation(api, 'list_session_threads', {}, options);
115
+ }
116
+ /**
117
+ * POST /v1/session/approvals: posts a tool approval card (idempotent by its
118
+ * id: the same id again answers 200 with the card's current state).
119
+ */
120
+ export function postSessionApproval(api, body, options = {}) {
121
+ return callOperation(api, 'post_session_approval', { body }, options);
122
+ }
123
+ /** GET /v1/session/approvals/{id}: a tool approval's state (the fallback when an event was missed). */
124
+ export function getSessionApproval(api, id, options = {}) {
125
+ return callOperation(api, 'get_session_approval', { path: { id } }, options);
126
+ }
127
+ /** POST /v1/session/approvals/{id}/close: closes a tool approval that no longer needs an answer. */
128
+ export function closeSessionApproval(api, id, reason, options = {}) {
129
+ return callOperation(api, 'close_session_approval', { path: { id }, body: { reason } }, options);
130
+ }
131
+ /**
132
+ * PUT /v1/session/capabilities: replaces the session's suggested capability
133
+ * tags with what it detects now (tag names only). It never changes the tags
134
+ * the owner confirmed. An older server (before 1.9.0) answers 404.
135
+ */
136
+ export function setSessionCapabilities(api, detected, options = {}) {
137
+ return callOperation(api, 'set_session_capabilities', { body: { detected: [...detected] } }, options);
138
+ }
139
+ /**
140
+ * POST /v1/session/handoffs: proposes handing a subtask of the session's own
141
+ * item to another of the owner's sessions (idempotent by `id`: the same id
142
+ * again answers the first handoff, created false). Only the owner's tap on
143
+ * the card hands it over.
144
+ */
145
+ export function proposeSessionHandoff(api, body, options = {}) {
146
+ return callOperation(api, 'propose_session_handoff', { body }, options);
147
+ }
148
+ /** GET /v1/session/handoffs/{id}: a handoff the session asked for, or was handed. */
149
+ export function getSessionHandoff(api, id, options = {}) {
150
+ return callOperation(api, 'get_session_handoff', { path: { id } }, options);
151
+ }
152
+ /** POST /v1/session/handoffs/{id}/close: withdraws the session's own proposal while its card is open. */
153
+ export function closeSessionHandoff(api, id, options = {}) {
154
+ return callOperation(api, 'close_session_handoff', { path: { id }, body: {} }, options);
155
+ }
156
+ /** POST /v1/session/handoffs/{id}/decline: hands a subtask handed to this session back, with why. */
157
+ export function declineSessionHandoff(api, id, reason, options = {}) {
158
+ return callOperation(api, 'decline_session_handoff', { path: { id }, body: { reason } }, options);
159
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The session's open tool approvals: the cards `approve` posted and still
3
+ * waits on, so the plugin's other hooks can end them.
4
+ *
5
+ * Claude Code never says that a permission request no longer needs an
6
+ * answer (the person answered in the terminal, pressed Esc, or the turn went
7
+ * on), so the plugin's hooks say it instead, and the waiting `approve` call
8
+ * ends with `{}` (it changes nothing) and closes its card in buildIt.Social:
9
+ *
10
+ * - `tool_done` (PostToolUse, PostToolUseFailure): the call ran, so it was
11
+ * answered elsewhere;
12
+ * - `agent_done` (SubagentStop): that subagent's approvals are no longer
13
+ * needed;
14
+ * - `turn_ended` (Stop) and `prompt_typed` (UserPromptSubmit): the main
15
+ * agent's approvals are no longer needed.
16
+ *
17
+ * On shutdown every open card is closed (the session ended). A journal file
18
+ * keeps the open cards' ids, so a server that died with cards open closes
19
+ * them when the next one starts in the folder: the owner never approves a
20
+ * call nobody waits for.
21
+ */
22
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
24
+ import { ApiError } from '../api/client.js';
25
+ import { closeSessionApproval } from './api.js';
26
+ /** The closer that calls the API (at most 5 seconds each; a failure is only logged). */
27
+ export function apiApprovalCloser(api, logger) {
28
+ return async (id, reason) => {
29
+ try {
30
+ await closeSessionApproval(api, id, reason, {
31
+ tool: 'approve',
32
+ signal: AbortSignal.timeout(5000),
33
+ });
34
+ return true;
35
+ }
36
+ catch (err) {
37
+ const code = err instanceof ApiError ? err.code : 'error';
38
+ logger.info('tool approval not closed', { message_id: id, code });
39
+ // Nothing to close: the post never made it.
40
+ return code === 'not_found';
41
+ }
42
+ };
43
+ }
44
+ /** How many ids of the session's tool approvals are remembered (to know their events). */
45
+ const KNOWN_MAX = 500;
46
+ /** How many ended ids are remembered (so a merged journal doesn't bring them back). */
47
+ const ENDED_MAX = 1000;
48
+ /** The longest shutdown waits for the cards to close. */
49
+ export const SHUTDOWN_CLOSE_MS = 5000;
50
+ export class ToolApprovals {
51
+ options;
52
+ open = new Map();
53
+ /** Every waiting call's settle, by card (the hook may run twice for one call). */
54
+ settles = new Map();
55
+ /** Ids of every approval this process posted (bounded), and the journal's from before. */
56
+ known = new Set();
57
+ /**
58
+ * Cards no waiting call holds but that may be open in buildIt.Social: the
59
+ * ones a process before this one left (the journal at startup), and posts
60
+ * that failed in a way that may have stored them, until they are closed.
61
+ */
62
+ leftOpen = [];
63
+ /** Ids this process knows have ended (the journal drops them). */
64
+ ended = new Set();
65
+ constructor(options) {
66
+ this.options = options;
67
+ this.leftOpen = this.readJournal();
68
+ for (const e of this.leftOpen)
69
+ this.remember(e.id);
70
+ }
71
+ /** The open approvals (tests). */
72
+ get openIds() {
73
+ return [...this.open.keys()];
74
+ }
75
+ /** Whether a card message is one of this session's tool approvals. */
76
+ isApproval(messageId) {
77
+ return this.known.has(messageId) || this.open.has(messageId);
78
+ }
79
+ /**
80
+ * An approval was posted (or is about to be): it is open until removed. A
81
+ * second call for the same card (the hook ran twice) joins it.
82
+ */
83
+ add(entry) {
84
+ const settles = this.settles.get(entry.id) ?? new Set();
85
+ settles.add(entry.settle);
86
+ this.settles.set(entry.id, settles);
87
+ if (!this.open.has(entry.id))
88
+ this.open.set(entry.id, entry);
89
+ this.remember(entry.id);
90
+ this.writeJournal();
91
+ return () => {
92
+ settles.delete(entry.settle);
93
+ if (settles.size > 0)
94
+ return;
95
+ this.settles.delete(entry.id);
96
+ if (this.open.delete(entry.id)) {
97
+ if (!this.leftOpen.some((e) => e.id === entry.id))
98
+ this.markEnded(entry.id);
99
+ this.writeJournal();
100
+ }
101
+ };
102
+ }
103
+ /**
104
+ * A post that failed in a way that may have stored it (a timeout, the
105
+ * network, a 5xx): the card stays in the journal until a close succeeds.
106
+ */
107
+ orphan(id, postedAt) {
108
+ if (!this.leftOpen.some((e) => e.id === id))
109
+ this.leftOpen.push({ id, posted_at: postedAt });
110
+ this.writeJournal();
111
+ }
112
+ /** A card closed for sure (or never stored): it leaves the journal. */
113
+ forget(id) {
114
+ this.leftOpen = this.leftOpen.filter((e) => e.id !== id);
115
+ if (!this.open.has(id))
116
+ this.markEnded(id);
117
+ this.writeJournal();
118
+ }
119
+ /** The tool call ran (its PostToolUse): an approval waiting for it was answered elsewhere. */
120
+ toolDone(toolUseId) {
121
+ let n = 0;
122
+ for (const entry of [...this.open.values()]) {
123
+ if (entry.toolUseId !== undefined && entry.toolUseId === toolUseId) {
124
+ this.settle(entry.id, 'answered_elsewhere');
125
+ n++;
126
+ }
127
+ }
128
+ return n;
129
+ }
130
+ /** An agent's approvals are no longer needed ("" is the main agent). */
131
+ agentDone(agentRef) {
132
+ let n = 0;
133
+ for (const entry of [...this.open.values()]) {
134
+ if (entry.agentRef === agentRef) {
135
+ this.settle(entry.id, 'stale');
136
+ n++;
137
+ }
138
+ }
139
+ return n;
140
+ }
141
+ /**
142
+ * Shutdown: closes every open approval's card (at most SHUTDOWN_CLOSE_MS
143
+ * in all). The waiting calls end with the session.
144
+ */
145
+ async closeAll(closer, reason) {
146
+ const ids = [...this.open.keys()];
147
+ if (ids.length === 0)
148
+ return;
149
+ let timer;
150
+ await Promise.race([
151
+ Promise.all(ids.map((id) => closer(id, reason).catch(() => undefined))),
152
+ new Promise((resolve) => {
153
+ timer = setTimeout(resolve, SHUTDOWN_CLOSE_MS);
154
+ }),
155
+ ]);
156
+ clearTimeout(timer);
157
+ this.options.logger.info('tool approvals closed on shutdown', { count: ids.length });
158
+ }
159
+ /**
160
+ * The cards an earlier process left open (it stopped while they waited):
161
+ * each is closed as interrupted, then the journal holds only this
162
+ * process's own. Called once the session is known.
163
+ */
164
+ async recover(closer) {
165
+ const left = this.leftOpen.filter((e) => !this.open.has(e.id));
166
+ if (left.length === 0)
167
+ return;
168
+ const closed = await Promise.all(left.map((e) => closer(e.id, 'interrupted').catch(() => false)));
169
+ // A close that failed keeps its card in the journal, for the next try.
170
+ left.forEach((e, i) => {
171
+ if (closed[i] === true)
172
+ this.forget(e.id);
173
+ });
174
+ this.options.logger.info('tool approvals left open by an earlier server closed', {
175
+ count: closed.filter(Boolean).length,
176
+ kept: closed.filter((c) => !c).length,
177
+ });
178
+ }
179
+ settle(id, reason) {
180
+ for (const settle of [...(this.settles.get(id) ?? [])])
181
+ settle(reason);
182
+ }
183
+ markEnded(id) {
184
+ this.ended.delete(id);
185
+ this.ended.add(id);
186
+ if (this.ended.size > ENDED_MAX) {
187
+ const oldest = this.ended.values().next().value;
188
+ if (oldest !== undefined)
189
+ this.ended.delete(oldest);
190
+ }
191
+ }
192
+ remember(id) {
193
+ this.known.delete(id);
194
+ this.known.add(id);
195
+ if (this.known.size > KNOWN_MAX) {
196
+ const oldest = this.known.values().next().value;
197
+ if (oldest !== undefined)
198
+ this.known.delete(oldest);
199
+ }
200
+ }
201
+ readJournal() {
202
+ const path = this.options.journal;
203
+ if (path === undefined)
204
+ return [];
205
+ try {
206
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
207
+ if (parsed.v !== 1 || !Array.isArray(parsed.open))
208
+ return [];
209
+ return parsed.open
210
+ .filter((e) => typeof e === 'object' && typeof e.id === 'string' && /^[0-9a-f-]{36}$/i.test(e.id))
211
+ .slice(0, 100)
212
+ .map((e) => ({ id: e.id, posted_at: typeof e.posted_at === 'string' ? e.posted_at : '' }));
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ }
218
+ /**
219
+ * Writes the journal: what it already lists (another server's entries
220
+ * stay), this process's open and left-open cards, less what this process
221
+ * knows has ended; to a temporary file, then renamed over it.
222
+ */
223
+ writeJournal() {
224
+ const path = this.options.journal;
225
+ if (path === undefined)
226
+ return;
227
+ const entries = new Map();
228
+ for (const e of [
229
+ ...this.readJournal(),
230
+ ...this.leftOpen,
231
+ ...[...this.open.values()].map((o) => ({ id: o.id, posted_at: o.postedAt })),
232
+ ]) {
233
+ if (!this.ended.has(e.id))
234
+ entries.set(e.id, e.posted_at);
235
+ }
236
+ const file = {
237
+ v: 1,
238
+ open: [...entries].slice(-100).map(([id, posted_at]) => ({ id, posted_at })),
239
+ };
240
+ const temp = `${path}.${process.pid}.tmp`;
241
+ try {
242
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
243
+ writeFileSync(temp, JSON.stringify(file), { mode: 0o600 });
244
+ chmodSync(temp, 0o600);
245
+ renameSync(temp, path);
246
+ }
247
+ catch (err) {
248
+ this.options.logger.info('tool approval journal not written', {
249
+ code: err.code ?? 'error',
250
+ });
251
+ try {
252
+ rmSync(temp, { force: true });
253
+ }
254
+ catch {
255
+ // Nothing to clean up.
256
+ }
257
+ }
258
+ }
259
+ }