mtech-ai-local-runner 0.1.46 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -2
- package/dist/src/adapters/claude-code/adapter.js +57 -36
- package/dist/src/adapters/claude-code/runtime-options.js +114 -33
- package/dist/src/asks/commands.js +100 -0
- package/dist/src/asks/doctor.js +106 -0
- package/dist/src/asks/hook-client.js +236 -0
- package/dist/src/asks/hub-protocol.js +97 -0
- package/dist/src/asks/hub.js +202 -0
- package/dist/src/asks/inbox.js +347 -0
- package/dist/src/asks/integrations.js +433 -0
- package/dist/src/asks/messenger.js +135 -0
- package/dist/src/asks/notifier.js +73 -0
- package/dist/src/asks/routing.js +239 -0
- package/dist/src/asks/runtime.js +198 -0
- package/dist/src/asks/shim.js +78 -0
- package/dist/src/asks/store.js +210 -0
- package/dist/src/asks/templates.js +44 -0
- package/dist/src/asks/types.js +64 -0
- package/dist/src/cli.js +90 -31
- package/dist/src/config.js +22 -2
- package/dist/src/daemon/runtime.js +36 -4
- package/dist/src/gateway-client.js +18 -3
- package/dist/src/registration/local-config.js +7 -1
- package/dist/src/safety/claude-binary-identity.js +81 -0
- package/dist/src/safety/local-edit-content.js +14 -0
- package/dist/src/safety/local-tool-policy.js +3 -0
- package/dist/src/safety/local-tool-server.js +2 -2
- package/dist/src/safety/local-tool-worker.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { HUB_SOCKET, HUB_TIMEOUT_MS, MAX_HOOK_INPUT_BYTES, MAX_LINE_BYTES, WATCH_LIMIT_MS, encodeLine, parseEventResponse, parseWatchMessage, } from "./hub-protocol.js";
|
|
7
|
+
import { MAX_CWD_LENGTH, SESSION_ID, isApp, isHookEvent } from "./types.js";
|
|
8
|
+
const STDIN_TIMEOUT_MS = 1000;
|
|
9
|
+
const STATE_ROOT_FLAG = "--state-root=";
|
|
10
|
+
/**
|
|
11
|
+
* Values of CLAUDE_CODE_ENTRYPOINT under which a person types the prompts: a terminal, the Claude desktop app and
|
|
12
|
+
* the VS Code extension. Claude Code exports the variable itself (sdk-cli for claude -p, sdk-ts and sdk-py for the
|
|
13
|
+
* Agent SDK, mcp, local-agent, remote and the GitHub action for the rest), so any other value is not a person.
|
|
14
|
+
*/
|
|
15
|
+
const INTERACTIVE_ENTRYPOINTS = new Set(["cli", "claude-desktop", "claude-vscode"]);
|
|
16
|
+
/** Where the Claude adapter puts each job's HOME: <stateRoot>/claude-jobs/<id>/home (src/adapters/claude-code/runtime-options.ts). */
|
|
17
|
+
const CLAUDE_JOBS_DIR = "claude-jobs";
|
|
18
|
+
/** True when HOME resolves inside <stateRoot>/claude-jobs/: a Claude job this runner started, never a person's session. */
|
|
19
|
+
function isRunnerJobHome(env, stateRoot) {
|
|
20
|
+
const home = env.HOME;
|
|
21
|
+
if (!home || !isAbsolute(home))
|
|
22
|
+
return false;
|
|
23
|
+
const roots = new Set([resolve(stateRoot)]);
|
|
24
|
+
// The adapter builds job homes from the real state root; the shim may name it through a symlink.
|
|
25
|
+
try {
|
|
26
|
+
roots.add(realpathSync(stateRoot));
|
|
27
|
+
}
|
|
28
|
+
catch { /* A state root that does not exist holds no jobs. */ }
|
|
29
|
+
const resolved = resolve(home);
|
|
30
|
+
return [...roots].some(root => resolved.startsWith(join(root, CLAUDE_JOBS_DIR) + sep));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* True when a person is attending the session that runs this hook (owner decision 2026-10-23). The one place that
|
|
34
|
+
* decides it, so it can be adjusted after the pilot.
|
|
35
|
+
*
|
|
36
|
+
* The runner's own jobs never count. A Claude job runs with its HOME under <stateRoot>/claude-jobs/, so a hook that
|
|
37
|
+
* sees such a HOME is refused whatever else the environment says. Codex jobs run with features.hooks=false (the Codex
|
|
38
|
+
* adapter's isolation overrides), so they never run a hook at all.
|
|
39
|
+
*
|
|
40
|
+
* Claude Code: CLAUDE_CODE_SESSION_ATTENDED, where the Claude Code version exports it, is the answer: "1" is a
|
|
41
|
+
* person, any other value is not, whatever the entrypoint says (a scheduled desktop task has the desktop entrypoint
|
|
42
|
+
* and nobody at the keyboard). A version that does not export it is judged by CLAUDE_CODE_ENTRYPOINT, and an unset
|
|
43
|
+
* or unknown entrypoint is not a person.
|
|
44
|
+
*
|
|
45
|
+
* Codex: no headless marker is exported to its hooks or present in its hook input that has been verified, so every
|
|
46
|
+
* Codex hook session counts as a person's. Claude's variables say nothing about Codex and are not read for it.
|
|
47
|
+
* `input` is the parsed hook input, passed for the day Codex exposes such a marker.
|
|
48
|
+
*/
|
|
49
|
+
export function isHumanSession(app, env, _input, stateRoot) {
|
|
50
|
+
if (isRunnerJobHome(env, stateRoot))
|
|
51
|
+
return false;
|
|
52
|
+
if (app !== "claude")
|
|
53
|
+
return true;
|
|
54
|
+
const attended = env.CLAUDE_CODE_SESSION_ATTENDED;
|
|
55
|
+
if (attended !== undefined && attended !== "")
|
|
56
|
+
return attended === "1";
|
|
57
|
+
return INTERACTIVE_ENTRYPOINTS.has(env.CLAUDE_CODE_ENTRYPOINT ?? "");
|
|
58
|
+
}
|
|
59
|
+
/** Same rule as defaultStateRootFromEnv in src/config.ts, repeated so this client loads nothing else. */
|
|
60
|
+
function stateRootFrom(env) {
|
|
61
|
+
return env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, "mtec-ai-local-runner") : join(env.HOME ?? "", ".local", "state", "mtec-ai-local-runner");
|
|
62
|
+
}
|
|
63
|
+
function parseArguments(argv, env) {
|
|
64
|
+
const rest = [...argv];
|
|
65
|
+
let stateRoot = stateRootFrom(env);
|
|
66
|
+
if (rest[0]?.startsWith(STATE_ROOT_FLAG)) {
|
|
67
|
+
stateRoot = rest.shift().slice(STATE_ROOT_FLAG.length);
|
|
68
|
+
if (!isAbsolute(stateRoot))
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const [app, event, ...extra] = rest;
|
|
72
|
+
if (extra.length > 0 || !isApp(app) || !isHookEvent(event))
|
|
73
|
+
return null;
|
|
74
|
+
return { stateRoot, app, event };
|
|
75
|
+
}
|
|
76
|
+
/** The hook's JSON from stdin, at most 64 KiB, within a second; null otherwise. */
|
|
77
|
+
async function readInput(stdin, timeoutMs) {
|
|
78
|
+
let timer;
|
|
79
|
+
const timeout = new Promise(resolve => { timer = setTimeout(() => resolve(null), timeoutMs); });
|
|
80
|
+
const read = (async () => {
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let size = 0;
|
|
83
|
+
for await (const chunk of stdin) {
|
|
84
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
85
|
+
size += bytes.length;
|
|
86
|
+
if (size > MAX_HOOK_INPUT_BYTES)
|
|
87
|
+
return null;
|
|
88
|
+
chunks.push(bytes);
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
91
|
+
})().catch(() => null);
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([read, timeout]);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The hook input as an object, or null when it is not one. */
|
|
100
|
+
function parseRecord(raw) {
|
|
101
|
+
let value;
|
|
102
|
+
try {
|
|
103
|
+
value = JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
109
|
+
}
|
|
110
|
+
/** Takes the session id, the cwd and Codex's stop_hook_active. Every other field, the prompt included, is ignored. */
|
|
111
|
+
function hookFields(input) {
|
|
112
|
+
const { session_id: sessionId, cwd, stop_hook_active: stopHookActive } = input;
|
|
113
|
+
if (typeof sessionId !== "string" || !SESSION_ID.test(sessionId))
|
|
114
|
+
return null;
|
|
115
|
+
return { sessionId, cwd: typeof cwd === "string" && isAbsolute(cwd) && cwd.length <= MAX_CWD_LENGTH ? cwd : "", stopHookActive: stopHookActive === true };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Writes one line to the hub and calls onLine for each line it answers. The first line must arrive within
|
|
119
|
+
* firstMs; after that the connection may stay open until limitMs. Resolves with what onLine settled on, or null.
|
|
120
|
+
* A connection the hub closes without a message (a refused or late watch ends that way) resolves null.
|
|
121
|
+
*/
|
|
122
|
+
function converse(socketPath, line, firstMs, limitMs, onLine) {
|
|
123
|
+
return new Promise(resolve => {
|
|
124
|
+
const socket = connect(socketPath);
|
|
125
|
+
let buffer = "";
|
|
126
|
+
let first = true;
|
|
127
|
+
let settled = false;
|
|
128
|
+
let timer = setTimeout(() => finish(null), firstMs);
|
|
129
|
+
const finish = (value) => {
|
|
130
|
+
if (settled)
|
|
131
|
+
return;
|
|
132
|
+
settled = true;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
socket.destroy();
|
|
135
|
+
resolve(value);
|
|
136
|
+
};
|
|
137
|
+
socket.setEncoding("utf8");
|
|
138
|
+
socket.on("error", () => finish(null));
|
|
139
|
+
socket.on("close", () => finish(null));
|
|
140
|
+
socket.on("data", (chunk) => {
|
|
141
|
+
buffer += chunk;
|
|
142
|
+
if (Buffer.byteLength(buffer) > MAX_LINE_BYTES * 4) {
|
|
143
|
+
finish(null);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let newline;
|
|
147
|
+
while (!settled && (newline = buffer.indexOf("\n")) >= 0) {
|
|
148
|
+
const text = buffer.slice(0, newline);
|
|
149
|
+
buffer = buffer.slice(newline + 1);
|
|
150
|
+
const step = onLine(text, first);
|
|
151
|
+
if (first) {
|
|
152
|
+
first = false;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
timer = setTimeout(() => finish(null), limitMs);
|
|
155
|
+
}
|
|
156
|
+
if (step.done)
|
|
157
|
+
finish(step.value);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
socket.write(line);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
function contextOutput(event, text) {
|
|
164
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } });
|
|
165
|
+
}
|
|
166
|
+
/** Returns the exit code: 2 only when a Claude Stop watcher wakes its session, else 0. Never throws. */
|
|
167
|
+
export async function runHookClient(argv, io) {
|
|
168
|
+
try {
|
|
169
|
+
const args = parseArguments(argv, io.env);
|
|
170
|
+
if (args === null)
|
|
171
|
+
return 0;
|
|
172
|
+
const raw = await readInput(io.stdin, io.stdinTimeoutMs ?? STDIN_TIMEOUT_MS);
|
|
173
|
+
const record = raw === null ? null : parseRecord(raw);
|
|
174
|
+
const input = record === null ? null : hookFields(record);
|
|
175
|
+
if (record === null || input === null)
|
|
176
|
+
return 0;
|
|
177
|
+
// Before the first byte goes to the hub: a session nobody attends is not registered, shown an ask or counted as seen.
|
|
178
|
+
if (!isHumanSession(args.app, io.env, record, args.stateRoot))
|
|
179
|
+
return 0;
|
|
180
|
+
const socketPath = join(args.stateRoot, HUB_SOCKET);
|
|
181
|
+
const hubTimeoutMs = io.hubTimeoutMs ?? HUB_TIMEOUT_MS;
|
|
182
|
+
if (args.app === "claude" && args.event === "stop") {
|
|
183
|
+
// The asyncRewake watcher: exit 2 with the text on stderr wakes Claude, even when the session is idle.
|
|
184
|
+
const text = await converse(socketPath, encodeLine({ v: 1, op: "watch", app: "claude", session_id: input.sessionId, cwd: input.cwd }), hubTimeoutMs, io.watchLimitMs ?? WATCH_LIMIT_MS, (line, first) => {
|
|
185
|
+
const message = parseWatchMessage(line);
|
|
186
|
+
if (message?.type === "wake")
|
|
187
|
+
return { done: true, value: message.text };
|
|
188
|
+
if (message?.type === "watching" && first)
|
|
189
|
+
return { done: false };
|
|
190
|
+
return { done: true, value: null };
|
|
191
|
+
});
|
|
192
|
+
if (text === null)
|
|
193
|
+
return 0;
|
|
194
|
+
io.stderr(text);
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
const request = args.event === "stop"
|
|
198
|
+
? { v: 1, op: "event", app: args.app, event: args.event, session_id: input.sessionId, cwd: input.cwd, stop_hook_active: input.stopHookActive }
|
|
199
|
+
: { v: 1, op: "event", app: args.app, event: args.event, session_id: input.sessionId, cwd: input.cwd };
|
|
200
|
+
const text = await converse(socketPath, encodeLine(request), hubTimeoutMs, hubTimeoutMs, line => ({ done: true, value: parseEventResponse(line)?.text ?? null }));
|
|
201
|
+
if (text === null || text === "")
|
|
202
|
+
return 0;
|
|
203
|
+
if (args.event === "session-start")
|
|
204
|
+
io.stdout(contextOutput("SessionStart", text));
|
|
205
|
+
else if (args.event === "prompt")
|
|
206
|
+
io.stdout(contextOutput("UserPromptSubmit", text));
|
|
207
|
+
else if (args.event === "stop")
|
|
208
|
+
io.stdout(JSON.stringify({ decision: "block", reason: text }));
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** True when Node runs this file as its entry point, directly or through a symlink. */
|
|
216
|
+
function isDirectInvocation(moduleUrl, argvPath) {
|
|
217
|
+
if (!argvPath)
|
|
218
|
+
return false;
|
|
219
|
+
try {
|
|
220
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
|
227
|
+
void runHookClient(process.argv.slice(2), {
|
|
228
|
+
stdin: process.stdin,
|
|
229
|
+
stdout: text => { process.stdout.write(`${text}\n`); },
|
|
230
|
+
stderr: text => { process.stderr.write(`${text}\n`); },
|
|
231
|
+
env: process.env,
|
|
232
|
+
}).then(code => {
|
|
233
|
+
process.exitCode = code;
|
|
234
|
+
process.stdin.destroy();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { MAX_CWD_LENGTH, SESSION_ID, isApp, isHookEvent } from "./types.js";
|
|
2
|
+
/*
|
|
3
|
+
* The line protocol between the hook client and the daemon's local hub (Work Item e1c08b33, spec section 8.2):
|
|
4
|
+
* one JSON object per line over the Unix socket <stateRoot>/hub.sock. The hook client imports this module, so it
|
|
5
|
+
* may import nothing but ./types.js.
|
|
6
|
+
*
|
|
7
|
+
* Client to hub, one line, then the client waits without closing its side:
|
|
8
|
+
* {"v":1,"op":"event","app":"claude"|"codex","event":"session-start"|"prompt"|"session-end","session_id":…,"cwd":…}
|
|
9
|
+
* {"v":1,"op":"event","app":"codex","event":"stop","session_id":…,"cwd":…,"stop_hook_active":false}
|
|
10
|
+
* {"v":1,"op":"watch","app":"claude","session_id":…,"cwd":…} (the Claude Stop watcher)
|
|
11
|
+
* {"v":1,"op":"status"} (doctor)
|
|
12
|
+
* Hub to client:
|
|
13
|
+
* event: {"v":1,"ok":true,"text":"…"|null}, then the hub closes the connection
|
|
14
|
+
* status: {"v":1,"ok":true,"status":{"deliveries":n,"live_sessions":n,"watchers":n}}
|
|
15
|
+
* error: {"v":1,"ok":false,"code":"invalid_request"|"unavailable"}
|
|
16
|
+
* watch: {"v":1,"type":"wake","text":"…"} at once, or {"v":1,"type":"watching"} and later at most one of
|
|
17
|
+
* {"v":1,"type":"wake","text":"…"} or {"v":1,"type":"release","reason":"replaced"|"session_ended"|"shutdown"}
|
|
18
|
+
* A watch the hub refuses, or answers too late, ends with a bare close and no message; the client exits 0 on that too.
|
|
19
|
+
*/
|
|
20
|
+
export const HUB_SOCKET = "hub.sock";
|
|
21
|
+
/** The hook client prints nothing and exits 0 when the hub has not answered within 300 ms. */
|
|
22
|
+
export const HUB_TIMEOUT_MS = 300;
|
|
23
|
+
/** A Claude Stop watcher exits 0 after 3500 s; its hook timeout is 3600 s. */
|
|
24
|
+
export const WATCH_LIMIT_MS = 3_500_000;
|
|
25
|
+
/** The most hook input the client reads from stdin. */
|
|
26
|
+
export const MAX_HOOK_INPUT_BYTES = 64 * 1024;
|
|
27
|
+
/** The longest request or response line. */
|
|
28
|
+
export const MAX_LINE_BYTES = 16 * 1024;
|
|
29
|
+
/** macOS keeps 104 bytes for a Unix socket path, including the terminating NUL; Node silently truncates longer ones. */
|
|
30
|
+
export const MAX_SOCKET_PATH_BYTES = 103;
|
|
31
|
+
export function encodeLine(message) {
|
|
32
|
+
return `${JSON.stringify(message)}\n`;
|
|
33
|
+
}
|
|
34
|
+
function record(line) {
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(line);
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A request from the hook client, or null when anything is missing, unknown or out of range. The session id and the
|
|
45
|
+
* cwd are held to the limits the ask store applies on a load (SESSION_ID, MAX_CWD_LENGTH): a session the store would
|
|
46
|
+
* drop at the next start must never be registered, because it would orphan every delivery pinned to it.
|
|
47
|
+
*/
|
|
48
|
+
export function parseHubRequest(line) {
|
|
49
|
+
const value = record(line);
|
|
50
|
+
if (value === null || value.v !== 1)
|
|
51
|
+
return null;
|
|
52
|
+
if (value.op === "status")
|
|
53
|
+
return Object.keys(value).length === 2 ? { v: 1, op: "status" } : null;
|
|
54
|
+
if (typeof value.session_id !== "string" || !SESSION_ID.test(value.session_id))
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof value.cwd !== "string" || value.cwd.length > MAX_CWD_LENGTH)
|
|
57
|
+
return null;
|
|
58
|
+
if (value.op === "watch") {
|
|
59
|
+
if (value.app !== "claude" || Object.keys(value).length !== 5)
|
|
60
|
+
return null;
|
|
61
|
+
return { v: 1, op: "watch", app: "claude", session_id: value.session_id, cwd: value.cwd };
|
|
62
|
+
}
|
|
63
|
+
if (value.op !== "event" || !isApp(value.app) || !isHookEvent(value.event))
|
|
64
|
+
return null;
|
|
65
|
+
// A Claude turn end is always a watch; only Codex sends a plain stop, with its loop guard.
|
|
66
|
+
if (value.event === "stop") {
|
|
67
|
+
if (value.app !== "codex" || typeof value.stop_hook_active !== "boolean" || Object.keys(value).length !== 7)
|
|
68
|
+
return null;
|
|
69
|
+
return { v: 1, op: "event", app: "codex", event: "stop", session_id: value.session_id, cwd: value.cwd, stop_hook_active: value.stop_hook_active };
|
|
70
|
+
}
|
|
71
|
+
if (Object.keys(value).length !== 6)
|
|
72
|
+
return null;
|
|
73
|
+
return { v: 1, op: "event", app: value.app, event: value.event, session_id: value.session_id, cwd: value.cwd };
|
|
74
|
+
}
|
|
75
|
+
/** The hub's answer to an event request, or null when it is not one. */
|
|
76
|
+
export function parseEventResponse(line) {
|
|
77
|
+
const value = record(line);
|
|
78
|
+
if (value === null || value.v !== 1 || value.ok !== true)
|
|
79
|
+
return null;
|
|
80
|
+
if (value.text === null)
|
|
81
|
+
return { text: null };
|
|
82
|
+
return typeof value.text === "string" ? { text: value.text } : null;
|
|
83
|
+
}
|
|
84
|
+
/** One message on a watch connection, or null when it is not one. */
|
|
85
|
+
export function parseWatchMessage(line) {
|
|
86
|
+
const value = record(line);
|
|
87
|
+
if (value === null || value.v !== 1)
|
|
88
|
+
return null;
|
|
89
|
+
if (value.type === "watching")
|
|
90
|
+
return { v: 1, type: "watching" };
|
|
91
|
+
if (value.type === "wake" && typeof value.text === "string" && value.text !== "")
|
|
92
|
+
return { v: 1, type: "wake", text: value.text };
|
|
93
|
+
if (value.type === "release" && (value.reason === "replaced" || value.reason === "session_ended" || value.reason === "shutdown")) {
|
|
94
|
+
return { v: 1, type: "release", reason: value.reason };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { chmod, lstat, mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { HUB_SOCKET, HUB_TIMEOUT_MS, MAX_LINE_BYTES, MAX_SOCKET_PATH_BYTES, encodeLine, parseHubRequest, } from "./hub-protocol.js";
|
|
5
|
+
/*
|
|
6
|
+
* The daemon's local hub (Work Item e1c08b33, spec section 8.2). A Unix socket, mode 0600 in the 0700 state
|
|
7
|
+
* root, so only this user can reach it. It answers one request per connection and keeps a Claude Stop watcher's
|
|
8
|
+
* connection open until the handler wakes or releases it. It holds no ask state itself: the handler does.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The hook client gives up after HUB_TIMEOUT_MS and prints nothing. A request that has waited this long, a little less
|
|
12
|
+
* to leave room for the write, can no longer be answered in time: the hub changes nothing for it.
|
|
13
|
+
*/
|
|
14
|
+
export const HUB_ANSWER_WINDOW_MS = HUB_TIMEOUT_MS - 100;
|
|
15
|
+
export function hubSocketPath(stateRoot) {
|
|
16
|
+
return join(stateRoot, HUB_SOCKET);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* True when nothing more can be sent to, or read from, the client. The client never half-closes its side, so an
|
|
20
|
+
* ended read side means it left. The socket's own state is read because its 'close' event can come several turns of
|
|
21
|
+
* the event loop later.
|
|
22
|
+
*/
|
|
23
|
+
function hasLeft(socket) {
|
|
24
|
+
return socket.destroyed || !socket.writable || socket.readableEnded;
|
|
25
|
+
}
|
|
26
|
+
function waitedTooLong(arrivedAt) {
|
|
27
|
+
return performance.now() - arrivedAt > HUB_ANSWER_WINDOW_MS;
|
|
28
|
+
}
|
|
29
|
+
/** The client of an event request whose line arrived at `arrivedAt` (performance.now()). */
|
|
30
|
+
export function clientFor(socket, arrivedAt) {
|
|
31
|
+
return { get gone() { return hasLeft(socket) || waitedTooLong(arrivedAt); } };
|
|
32
|
+
}
|
|
33
|
+
/** The watcher for one watch connection. `answered` is true once anything was sent on it. */
|
|
34
|
+
export function watcherFor(socket, arrivedAt = performance.now()) {
|
|
35
|
+
let closeEvent = false;
|
|
36
|
+
let answered = false;
|
|
37
|
+
const listeners = [];
|
|
38
|
+
socket.once("close", () => {
|
|
39
|
+
closeEvent = true;
|
|
40
|
+
for (const listener of listeners.splice(0))
|
|
41
|
+
listener();
|
|
42
|
+
});
|
|
43
|
+
const closed = () => closeEvent || hasLeft(socket);
|
|
44
|
+
const send = (message, last) => {
|
|
45
|
+
if (closed())
|
|
46
|
+
return false;
|
|
47
|
+
socket.write(encodeLine(message));
|
|
48
|
+
answered = true;
|
|
49
|
+
if (last)
|
|
50
|
+
socket.end();
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
get closed() { return closed(); },
|
|
55
|
+
// Once something was sent the client has its answer; only a watch that is still unanswered can be too late.
|
|
56
|
+
get gone() { return closed() || (!answered && waitedTooLong(arrivedAt)); },
|
|
57
|
+
get answered() { return answered; },
|
|
58
|
+
hold: () => send({ v: 1, type: "watching" }, false),
|
|
59
|
+
wake: text => send({ v: 1, type: "wake", text }, true),
|
|
60
|
+
release: reason => { send({ v: 1, type: "release", reason }, true); },
|
|
61
|
+
onClose: listener => { if (closeEvent)
|
|
62
|
+
listener();
|
|
63
|
+
else
|
|
64
|
+
listeners.push(listener); },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** A socket left behind by a daemon that died is removed; anything else at that path is never touched. */
|
|
68
|
+
async function removeStaleSocket(path) {
|
|
69
|
+
try {
|
|
70
|
+
if (!(await lstat(path)).isSocket())
|
|
71
|
+
throw new Error("ASK_HUB_PATH_NOT_A_SOCKET");
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === "ENOENT")
|
|
75
|
+
return;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
await rm(path, { force: true });
|
|
79
|
+
}
|
|
80
|
+
export class AskHub {
|
|
81
|
+
options;
|
|
82
|
+
server = null;
|
|
83
|
+
sockets = new Set();
|
|
84
|
+
constructor(options) {
|
|
85
|
+
this.options = options;
|
|
86
|
+
}
|
|
87
|
+
get listening() {
|
|
88
|
+
return this.server !== null;
|
|
89
|
+
}
|
|
90
|
+
async start() {
|
|
91
|
+
// A second listener would remove the live socket as if it were stale.
|
|
92
|
+
if (this.server !== null)
|
|
93
|
+
throw new Error("ASK_HUB_ALREADY_LISTENING");
|
|
94
|
+
const path = this.options.socketPath;
|
|
95
|
+
if (Buffer.byteLength(path) > MAX_SOCKET_PATH_BYTES)
|
|
96
|
+
throw new Error("ASK_HUB_SOCKET_PATH_TOO_LONG");
|
|
97
|
+
const directory = dirname(path);
|
|
98
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
99
|
+
await chmod(directory, 0o700);
|
|
100
|
+
await removeStaleSocket(path);
|
|
101
|
+
const server = createServer(socket => this.connection(socket));
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
server.once("error", reject);
|
|
104
|
+
server.listen(path, () => { server.off("error", reject); resolve(); });
|
|
105
|
+
});
|
|
106
|
+
// A listening server can still emit an error (accepting with no file descriptors left, say); without a listener Node
|
|
107
|
+
// would throw it and take the daemon down. Only its code is logged.
|
|
108
|
+
server.on("error", error => this.logServerError(error));
|
|
109
|
+
try {
|
|
110
|
+
// listen() creates the socket with the umask's mode (0755); only this user may connect.
|
|
111
|
+
await chmod(path, 0o600);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// Never leave a socket listening that is not private, nor a connection that was accepted on it.
|
|
115
|
+
for (const socket of this.sockets)
|
|
116
|
+
socket.destroy();
|
|
117
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
118
|
+
await rm(path, { force: true });
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
this.server = server;
|
|
122
|
+
}
|
|
123
|
+
async stop() {
|
|
124
|
+
const server = this.server;
|
|
125
|
+
this.server = null;
|
|
126
|
+
if (server === null)
|
|
127
|
+
return;
|
|
128
|
+
for (const socket of this.sockets)
|
|
129
|
+
socket.destroy();
|
|
130
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
131
|
+
await rm(this.options.socketPath, { force: true });
|
|
132
|
+
}
|
|
133
|
+
connection(socket) {
|
|
134
|
+
this.sockets.add(socket);
|
|
135
|
+
socket.once("close", () => this.sockets.delete(socket));
|
|
136
|
+
socket.on("error", () => undefined);
|
|
137
|
+
socket.setEncoding("utf8");
|
|
138
|
+
// A client that never sends a full line is dropped; a watch request clears the timer and stays open.
|
|
139
|
+
socket.setTimeout(this.options.requestTimeoutMs ?? 2000, () => socket.destroy());
|
|
140
|
+
let buffer = "";
|
|
141
|
+
const onData = (chunk) => {
|
|
142
|
+
buffer += chunk;
|
|
143
|
+
const newline = buffer.indexOf("\n");
|
|
144
|
+
// A line over the limit is dropped without an answer, whether or not its end has arrived yet.
|
|
145
|
+
if (Buffer.byteLength(newline < 0 ? buffer : buffer.slice(0, newline)) > MAX_LINE_BYTES) {
|
|
146
|
+
socket.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (newline < 0)
|
|
150
|
+
return;
|
|
151
|
+
const arrivedAt = performance.now();
|
|
152
|
+
socket.off("data", onData);
|
|
153
|
+
// Keep reading, so the client leaving is seen while its request waits.
|
|
154
|
+
socket.resume();
|
|
155
|
+
socket.setTimeout(0);
|
|
156
|
+
void this.dispatch(buffer.slice(0, newline), socket, arrivedAt);
|
|
157
|
+
};
|
|
158
|
+
socket.on("data", onData);
|
|
159
|
+
}
|
|
160
|
+
async dispatch(line, socket, arrivedAt) {
|
|
161
|
+
const reply = (response) => { if (!hasLeft(socket))
|
|
162
|
+
socket.end(encodeLine(response)); };
|
|
163
|
+
const request = parseHubRequest(line);
|
|
164
|
+
if (request === null) {
|
|
165
|
+
reply({ v: 1, ok: false, code: "invalid_request" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
if (request.op === "status") {
|
|
170
|
+
reply({ v: 1, ok: true, status: this.options.handler.status() });
|
|
171
|
+
}
|
|
172
|
+
else if (request.op === "event") {
|
|
173
|
+
reply({ v: 1, ok: true, text: await this.options.handler.event(request, clientFor(socket, arrivedAt)) });
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const watcher = watcherFor(socket, arrivedAt);
|
|
177
|
+
await this.options.handler.watch(request, watcher);
|
|
178
|
+
// The handler sent nothing: it refused a client that had gone. Do not leave a connection open that nobody answers.
|
|
179
|
+
if (!watcher.answered)
|
|
180
|
+
socket.destroy();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
this.logFailure(request.op, error);
|
|
185
|
+
reply({ v: 1, ok: false, code: "unavailable" });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
logServerError(error) {
|
|
189
|
+
const code = error?.code;
|
|
190
|
+
try {
|
|
191
|
+
this.options.log?.({ event: "ask_hub_server_error", code: typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "UNKNOWN" });
|
|
192
|
+
}
|
|
193
|
+
catch { /* Logging never stops the hub. */ }
|
|
194
|
+
}
|
|
195
|
+
logFailure(op, error) {
|
|
196
|
+
const code = error?.code;
|
|
197
|
+
try {
|
|
198
|
+
this.options.log?.({ event: "ask_hub_handler_failed", op, error: typeof code === "string" ? code : error instanceof Error ? error.name : "UNKNOWN" });
|
|
199
|
+
}
|
|
200
|
+
catch { /* Logging never stops the hub. */ }
|
|
201
|
+
}
|
|
202
|
+
}
|