@sgtbeatdown/wolfpack-tasks 0.0.0-stage → 0.2.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.
@@ -0,0 +1,37 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+
4
+ import { createClaudeTaskServer } from "./mcp-server";
5
+ import type { CommandRunner } from "./wake";
6
+
7
+ /** Runs an argv array without a shell. A non-zero exit is a result, not an error: callers read stdout either way. */
8
+ const runCommand: CommandRunner = (argv, options) => new Promise((resolve, reject) => {
9
+ const [file, ...args] = argv;
10
+ if (!file) { reject(new Error("empty command")); return; }
11
+ execFile(file, args, { timeout: options.timeoutMs, maxBuffer: 1024 * 1024, encoding: "utf8", windowsHide: true }, (error, stdout, stderr) => {
12
+ if (error && typeof error.code !== "number") { reject(error); return; }
13
+ resolve({ exitCode: error ? Number(error.code) : 0, stdout, stderr });
14
+ });
15
+ });
16
+
17
+ // Stdout carries only JSON-RPC. Route stray console output from any bundled code to stderr.
18
+ console.log = console.info = console.debug = (...values: unknown[]) => console.error(...values);
19
+
20
+ const server = createClaudeTaskServer({
21
+ env: process.env,
22
+ send: (message) => { process.stdout.write(`${JSON.stringify(message)}\n`); },
23
+ runCommand,
24
+ log: (message) => { process.stderr.write(`${message}\n`); },
25
+ });
26
+
27
+ let stopping: Promise<void> | undefined;
28
+ const stop = (): Promise<void> => {
29
+ stopping ??= server.shutdown().catch(() => undefined).finally(() => process.exit(0));
30
+ return stopping;
31
+ };
32
+
33
+ // Requests run concurrently: a long agent_task_wait must not block pings, cancellations or other tools.
34
+ createInterface({ input: process.stdin, crlfDelay: Number.POSITIVE_INFINITY })
35
+ .on("line", (line) => { void server.handleLine(line); })
36
+ .on("close", () => { void stop(); });
37
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, () => { void stop(); });
@@ -0,0 +1,66 @@
1
+ import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ import type { WakeMode } from "./wake";
5
+
6
+ export const STATUS_FILE_VERSION = 1;
7
+
8
+ export interface ClaudeStatus {
9
+ readonly version: typeof STATUS_FILE_VERSION;
10
+ readonly sessionId: string;
11
+ readonly sessionName: string;
12
+ readonly pid: number;
13
+ readonly wake: WakeMode;
14
+ /** The MCP protocol version this server answered. */
15
+ readonly protocolVersion: string;
16
+ /** The version the client proposed, when it was a string. */
17
+ readonly clientProtocolVersion: string | null;
18
+ readonly startedAt: number;
19
+ readonly updatedAt: number;
20
+ readonly lastPointerAt: number | null;
21
+ readonly lastInboxAt: number | null;
22
+ readonly lastNudgeAt: number | null;
23
+ readonly fallbackAt: number | null;
24
+ readonly lastNudgeError: string | null;
25
+ /** Failed wolfpack status checks for the current pointer; a positive value means the fallback is retrying. */
26
+ readonly statusFailures: number;
27
+ /** When a pointer's fallback was given up after its status retries; null after the next inbox call. */
28
+ readonly wakeStalledAt: number | null;
29
+ /** The runtime's last status-line text; null when clear. */
30
+ readonly lastStatus: string | null;
31
+ }
32
+
33
+ export type ClaudeStatusPatch = Partial<Omit<ClaudeStatus, "version" | "sessionId" | "sessionName" | "pid" | "startedAt" | "updatedAt">>;
34
+
35
+ export interface ClaudeStatusFile {
36
+ readonly path: string;
37
+ update(patch: ClaudeStatusPatch): void;
38
+ }
39
+
40
+ /** Diagnostic status for operators and tests, rewritten atomically on change; never read back as task state. */
41
+ export function createStatusFile(path: string, initial: Omit<ClaudeStatus, "version" | "updatedAt">, now: () => number, log: (message: string) => void): ClaudeStatusFile {
42
+ let status: ClaudeStatus = { version: STATUS_FILE_VERSION, ...initial, updatedAt: now() };
43
+ let failed = false;
44
+ const write = (): void => {
45
+ try {
46
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
47
+ const temporary = `${path}.${process.pid}.tmp`;
48
+ writeFileSync(temporary, `${JSON.stringify(status, null, 2)}\n`, { mode: 0o600 });
49
+ chmodSync(temporary, 0o600);
50
+ renameSync(temporary, path);
51
+ failed = false;
52
+ } catch (error) {
53
+ // Best effort: report the first failure of a streak, never fail task work over diagnostics.
54
+ if (!failed) log(`wolfpack-tasks: status file write failed: ${error instanceof Error ? error.message : String(error)}`);
55
+ failed = true;
56
+ }
57
+ };
58
+ write();
59
+ return {
60
+ path,
61
+ update(patch) {
62
+ status = { ...status, ...patch, updatedAt: now() };
63
+ write();
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,28 @@
1
+ import { Compile } from "typebox/compile";
2
+ import type { TSchema } from "typebox";
3
+ import { Value } from "typebox/value";
4
+
5
+ const validators = new WeakMap<TSchema, ReturnType<typeof Compile>>();
6
+
7
+ /**
8
+ * Mirrors Pi's tool argument validation (pi-ai `validateToolArguments` for TypeBox schemas): clone, convert, check,
9
+ * and the same error text, so a Claude model sees the same feedback for the same invalid call.
10
+ */
11
+ export function validateToolArguments(name: string, schema: TSchema, input: unknown): unknown {
12
+ const args = structuredClone(input);
13
+ Value.Convert(schema, args);
14
+ let validator = validators.get(schema);
15
+ if (!validator) { validator = Compile(schema); validators.set(schema, validator); }
16
+ if (validator.Check(args)) return args;
17
+ const errors = validator.Errors(args).map((error) => ` - ${validationPath(error)}: ${error.message}`).join("\n") || "Unknown validation error";
18
+ throw new Error(`Validation failed for tool "${name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(input, null, 2)}`);
19
+ }
20
+
21
+ function validationPath(error: { readonly keyword: string; readonly instancePath: string; readonly params: unknown }): string {
22
+ const base = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
23
+ if (error.keyword === "required") {
24
+ const property = (error.params as { readonly requiredProperties?: readonly string[] }).requiredProperties?.[0];
25
+ if (property) return base ? `${base}.${property}` : property;
26
+ }
27
+ return base || "root";
28
+ }
@@ -0,0 +1,215 @@
1
+ import { CLAUDE_PLUGIN } from "./constants";
2
+
3
+ /** Relay-side bound for a held delivery to reach its receiver's model; the fallback must finish well inside it. */
4
+ export const RELAY_WAKE_DEADLINE_MS = 120_000;
5
+
6
+ export interface WakeTiming {
7
+ /** Channel mode: how long a pointer may stay unanswered before the fallback is considered. */
8
+ readonly channelGraceMs: number;
9
+ /** Fallback mode: the same delay once the channel was found not to wake the model. */
10
+ readonly fallbackGraceMs: number;
11
+ /** After a Stop hook, the quiet period before an idle session is nudged. */
12
+ readonly settleMs: number;
13
+ /** Per wolfpack CLI command. */
14
+ readonly commandTimeoutMs: number;
15
+ /** Spacing between status checks for the same pointer after a failed or unreadable status. */
16
+ readonly statusRetryMs: number;
17
+ /** Status checks per pointer before its fallback is given up and the pointer is released. */
18
+ readonly statusAttempts: number;
19
+ }
20
+
21
+ export const DEFAULT_WAKE_TIMING: WakeTiming = Object.freeze({ channelGraceMs: 15_000, fallbackGraceMs: 2_000, settleMs: 3_000, commandTimeoutMs: 10_000, statusRetryMs: 5_000, statusAttempts: 3 });
22
+
23
+ /**
24
+ * A nudged pointer that is still unanswered this long after its nudge is replaced by the next pointer request, so an
25
+ * uncertain or ignored nudge cannot hold later mail forever. It also bounds a repeated paste to one per period.
26
+ */
27
+ export const NUDGED_POINTER_EXPIRY_MS = RELAY_WAKE_DEADLINE_MS;
28
+
29
+ export type WakeMode = "channel" | "fallback";
30
+ export type SessionActivity = "busy" | "idle";
31
+
32
+ export interface WakeTimers {
33
+ now(): number;
34
+ /** Returns a cancel function. */
35
+ setTimeout(fire: () => void, ms: number): () => void;
36
+ }
37
+
38
+ export interface CommandResult {
39
+ readonly exitCode: number;
40
+ readonly stdout: string;
41
+ readonly stderr: string;
42
+ }
43
+
44
+ /** Runs an argv array without a shell. */
45
+ export type CommandRunner = (argv: readonly string[], options: { readonly timeoutMs: number }) => Promise<CommandResult>;
46
+
47
+ export interface WakeStatusPatch {
48
+ readonly wake?: WakeMode;
49
+ readonly lastPointerAt?: number;
50
+ readonly lastInboxAt?: number;
51
+ readonly lastNudgeAt?: number;
52
+ readonly fallbackAt?: number;
53
+ readonly lastNudgeError?: string;
54
+ /** Failed status checks for the current pointer. */
55
+ readonly statusFailures?: number;
56
+ /** Set when a pointer's fallback was given up after its status retries; cleared by the next inbox call. */
57
+ readonly wakeStalledAt?: number | null;
58
+ }
59
+
60
+ export interface WakeControllerOptions {
61
+ readonly sessionId: string;
62
+ sendChannel(meta: Readonly<Record<string, string>>): void;
63
+ readonly runCommand: CommandRunner;
64
+ readonly timers: WakeTimers;
65
+ readonly timing?: WakeTiming;
66
+ onChange(patch: WakeStatusPatch): void;
67
+ }
68
+
69
+ export interface WakeController {
70
+ readonly mode: WakeMode;
71
+ /** True from a pointer request until `inboxCalled`. */
72
+ readonly pointerOutstanding: boolean;
73
+ /** Coalesced: at most one pointer is outstanding until `inboxCalled`. */
74
+ requestPointer(meta: Readonly<Record<string, string>>): void;
75
+ inboxCalled(): void;
76
+ activity(state: SessionActivity): void;
77
+ close(): void;
78
+ }
79
+
80
+ interface Outstanding {
81
+ readonly at: number;
82
+ /** The once-per-pointer nudge started its terminal sends (successfully or not). */
83
+ nudged: boolean;
84
+ nudgedAt?: number;
85
+ nudging: boolean;
86
+ statusFailures: number;
87
+ }
88
+
89
+ /**
90
+ * Channel pointer plus the D6 fallback. Activity comes from Claude Code hooks: UserPromptSubmit and PreToolUse mean
91
+ * busy, Stop means idle. A fresh session starts idle because no turn has begun. The fallback never runs while busy.
92
+ *
93
+ * Failure policy: a status check has no terminal side effect, so a failed, unreadable or not-ready status (or a runner
94
+ * error such as ENOENT) is retried for the same idle pointer up to `statusAttempts`; then the pointer is released and
95
+ * the next pointer request starts over. A paste or submit that fails, times out or throws is uncertain: the text may
96
+ * already be in the prompt input. It is never repeated and never followed by a submit for that pointer; the pointer
97
+ * expires `NUDGED_POINTER_EXPIRY_MS` after the nudge.
98
+ */
99
+ export function createWakeController(options: WakeControllerOptions): WakeController {
100
+ const timing = options.timing ?? DEFAULT_WAKE_TIMING;
101
+ let mode: WakeMode = "channel";
102
+ let activity: SessionActivity = "idle";
103
+ let outstanding: Outstanding | undefined;
104
+ let cancelTimer: (() => void) | undefined;
105
+ let closed = false;
106
+
107
+ const grace = (): number => mode === "channel" ? timing.channelGraceMs : timing.fallbackGraceMs;
108
+ const schedule = (delayMs: number): void => {
109
+ cancelTimer?.();
110
+ cancelTimer = options.timers.setTimeout(() => { cancelTimer = undefined; check(); }, Math.max(0, delayMs));
111
+ };
112
+ const check = (): void => {
113
+ const pending = outstanding;
114
+ if (closed || !pending || pending.nudged || pending.nudging || activity === "busy") return;
115
+ const remaining = pending.at + grace() - options.timers.now();
116
+ if (remaining > 0) { schedule(remaining); return; }
117
+ void nudge(pending);
118
+ };
119
+ /** Why a nudge for `pending` must not take its next terminal step, or undefined when it may. */
120
+ const fenced = (pending: Outstanding): string | undefined => {
121
+ if (closed) return "controller closed";
122
+ if (outstanding !== pending) return "pointer acknowledged";
123
+ return activity === "busy" ? "session busy" : undefined;
124
+ };
125
+ /** Status patches after an await: a closed controller writes nothing more. */
126
+ const report = (patch: WakeStatusPatch): void => { if (!closed) options.onChange(patch); };
127
+ const command = async (argv: readonly string[]): Promise<CommandResult> => options.runCommand(argv, { timeoutMs: timing.commandTimeoutMs });
128
+ const statusFailed = (pending: Outstanding, reason: string): void => {
129
+ pending.statusFailures++;
130
+ if (closed || outstanding !== pending) return;
131
+ if (pending.statusFailures < timing.statusAttempts) {
132
+ report({ lastNudgeError: reason, statusFailures: pending.statusFailures });
133
+ if (activity === "idle") schedule(timing.statusRetryMs);
134
+ return;
135
+ }
136
+ outstanding = undefined;
137
+ report({ lastNudgeError: `${reason}; fallback given up after ${pending.statusFailures} status checks`, statusFailures: pending.statusFailures, wakeStalledAt: options.timers.now() });
138
+ };
139
+ const nudge = async (pending: Outstanding): Promise<void> => {
140
+ pending.nudging = true;
141
+ try {
142
+ let reason: string | undefined;
143
+ try {
144
+ reason = readyFailure(await command(["wolfpack", "session", "status", options.sessionId, "--json"]), options.sessionId);
145
+ } catch (error) {
146
+ reason = error instanceof Error ? error.message : String(error);
147
+ }
148
+ if (reason !== undefined) { statusFailed(pending, reason); return; }
149
+ // Busy or acknowledged meanwhile: the next Stop hook re-arms the check.
150
+ if (fenced(pending) !== undefined) return;
151
+ const at = options.timers.now();
152
+ pending.nudged = true;
153
+ pending.nudgedAt = at;
154
+ const paste = await command(["wolfpack", "session", "send", options.sessionId, CLAUDE_PLUGIN.pointer, "--json"]);
155
+ if (paste.exitCode !== 0) { report({ lastNudgeAt: at, lastNudgeError: `session send exited ${paste.exitCode}` }); return; }
156
+ // The paste is out; the submit is a second terminal side effect and must not reach a session that changed meanwhile.
157
+ // The pointer text stays unsubmitted in the prompt input, the same best-effort residue as any appended nudge.
158
+ const fence = fenced(pending);
159
+ if (fence !== undefined) { report({ lastNudgeAt: at, lastNudgeError: `submit skipped: ${fence} during paste` }); return; }
160
+ if (mode === "channel") { mode = "fallback"; report({ wake: mode, fallbackAt: at }); }
161
+ const submit = await command(["wolfpack", "session", "send", options.sessionId, "\r", "--no-enter", "--json"]);
162
+ report(submit.exitCode === 0 ? { lastNudgeAt: at } : { lastNudgeAt: at, lastNudgeError: `session submit exited ${submit.exitCode}` });
163
+ } catch (error) {
164
+ report({ lastNudgeError: error instanceof Error ? error.message : String(error) });
165
+ } finally {
166
+ pending.nudging = false;
167
+ }
168
+ };
169
+
170
+ return {
171
+ get mode() { return mode; },
172
+ get pointerOutstanding() { return outstanding !== undefined; },
173
+ requestPointer(meta) {
174
+ if (closed) return;
175
+ const at = options.timers.now();
176
+ if (outstanding && !(outstanding.nudgedAt !== undefined && at - outstanding.nudgedAt >= NUDGED_POINTER_EXPIRY_MS)) return;
177
+ outstanding = { at, nudged: false, nudging: false, statusFailures: 0 };
178
+ if (mode === "channel") options.sendChannel(meta);
179
+ options.onChange({ lastPointerAt: at, statusFailures: 0 });
180
+ schedule(grace());
181
+ },
182
+ inboxCalled() {
183
+ outstanding = undefined;
184
+ cancelTimer?.();
185
+ cancelTimer = undefined;
186
+ if (!closed) options.onChange({ lastInboxAt: options.timers.now(), wakeStalledAt: null });
187
+ },
188
+ activity(state) {
189
+ activity = state;
190
+ if (state === "busy") { cancelTimer?.(); cancelTimer = undefined; return; }
191
+ const pending = outstanding;
192
+ if (closed || !pending || pending.nudged) return;
193
+ schedule(Math.max(timing.settleMs, pending.at + grace() - options.timers.now()));
194
+ },
195
+ close() {
196
+ closed = true;
197
+ cancelTimer?.();
198
+ cancelTimer = undefined;
199
+ },
200
+ };
201
+ }
202
+
203
+ /** Undefined when `wolfpack session status --json` shows this live, ready terminal; otherwise the reason. */
204
+ function readyFailure(result: CommandResult, sessionId: string): string | undefined {
205
+ let status: unknown;
206
+ try { status = JSON.parse(result.stdout); } catch { return `session status unreadable (exit ${result.exitCode})`; }
207
+ if (!isRecord(status) || status.ok !== true || status.sessionId !== sessionId) return "session status does not match this session";
208
+ const terminal = status.terminal;
209
+ if (status.state !== "active" || !isRecord(terminal) || terminal.exists !== true || terminal.alive !== true || terminal.status !== "ready") return "session terminal is not live and ready";
210
+ return undefined;
211
+ }
212
+
213
+ function isRecord(value: unknown): value is Record<string, unknown> {
214
+ return value !== null && typeof value === "object" && !Array.isArray(value);
215
+ }
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { WORKER_GATE_DENIAL_CODE } from "../task-worker-mode";
4
+ import { decideWorkerGateHook } from "./worker-gate";
5
+
6
+ // Entry point of the plugin's fail-closed PreToolUse command hook (dist/worker-gate.mjs). Exit 2 blocks the tool call.
7
+ let stdinRead = false;
8
+ let result;
9
+ try {
10
+ result = await decideWorkerGateHook({
11
+ env: process.env,
12
+ readStdin: () => {
13
+ stdinRead = true;
14
+ return readFileSync(0, "utf8");
15
+ },
16
+ });
17
+ } catch (error) {
18
+ // Unreachable by design; any other exit code would also block under onFailure: "block".
19
+ result = { exitCode: 2 as const, stderr: `${WORKER_GATE_DENIAL_CODE}: native tools are blocked: worker gate failed: ${error instanceof Error ? error.message : String(error)}` };
20
+ }
21
+ // Claude Code fails a hook that exits before its input is written (EPIPE), and onFailure: "block" turns that into a block.
22
+ if (!stdinRead) {
23
+ try { readFileSync(0); } catch { /* stdin is already closed: nothing to drain */ }
24
+ }
25
+ if (result.stderr) process.stderr.write(`${result.stderr}\n`);
26
+ process.exitCode = result.exitCode;
@@ -0,0 +1,260 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, lstatSync, mkdirSync, renameSync, rmSync, unlinkSync } from "node:fs";
3
+ import { createConnection, createServer } from "node:net";
4
+ import type { Socket } from "node:net";
5
+ import { dirname, join } from "node:path";
6
+
7
+ import { taskWorkerModeEnabled, WORKER_GATE_DENIAL_CODE } from "../task-worker-mode";
8
+ import { CLAUDE_PLUGIN, HARNESS_PRE_ASSIGNMENT_TOOLS } from "./constants";
9
+ import { readClaudeEnvironment } from "./environment";
10
+
11
+ /**
12
+ * Fail-closed worker gate for Claude Code native tools.
13
+ *
14
+ * The `mcp_tool` `PreToolUse` hook fails open when the MCP server is disconnected, errors or times out. A `command`
15
+ * hook with `onFailure: "block"` does not: Claude Code counts a hook that cannot start, times out, or exits with a code
16
+ * other than 0 or 2 as exit code 2, which blocks the tool call. That hook runs `decideWorkerGateHook`, which asks the
17
+ * running MCP server over a unix-domain socket and denies unless the server answers now with an authorization for this
18
+ * session. Only a listening server can answer, so a stopped, killed or hung server denies: there is no stored
19
+ * authorization to outlive it. This module and its script import no task runtime, so the script starts quickly.
20
+ *
21
+ * Protocol (one line each way, JSON, newline-terminated):
22
+ * request `{"v":1,"session":<WOLFPACK_SESSION_ID>,"tool":<tool name>}`
23
+ * reply `{"v":1,"session":<server session id>,"authorized":<boolean>,"reason":<string|null>}`
24
+ */
25
+
26
+ export const WORKER_GATE_PROTOCOL_VERSION = 1;
27
+ /** The hook denies when the server has not answered within this time; the hook itself has a 10 s Claude Code timeout. */
28
+ export const WORKER_GATE_TIMEOUT_MS = 3_000;
29
+ /** The server waits at most this long for a fresh evaluation, then answers from the latest one. */
30
+ export const WORKER_GATE_ANSWER_WAIT_MS = 1_000;
31
+ /** The server closes a connection this long after accept, answered or not; below the hook's timeout. */
32
+ export const WORKER_GATE_REQUEST_DEADLINE_MS = 2_000;
33
+ /** Connections the server holds at once; it closes more at accept, so slow clients cannot take every descriptor. */
34
+ export const WORKER_GATE_MAX_CONNECTIONS = 16;
35
+ export const WORKER_GATE_REQUEST_MAX_BYTES = 4_096;
36
+ export const WORKER_GATE_REPLY_MAX_BYTES = 16_384;
37
+ /** Bound on the denial text the hook prints; Claude Code shows it to the model. */
38
+ export const WORKER_GATE_REASON_MAX_CHARS = 8_192;
39
+ /** macOS `sun_path` holds 104 bytes including the terminating NUL; Linux holds 108. The smaller limit applies. */
40
+ export const UNIX_SOCKET_PATH_MAX_BYTES = 103;
41
+ const TOOL_NAME_MAX_CHARS = 256;
42
+
43
+ export const socketPathFits = (path: string): boolean => Buffer.byteLength(path) <= UNIX_SOCKET_PATH_MAX_BYTES;
44
+
45
+ export interface WorkerGateAnswer {
46
+ readonly authorized: boolean;
47
+ /** The runtime's denial reason when unauthorized; null when authorized or when the runtime gave none. */
48
+ readonly reason: string | null;
49
+ }
50
+
51
+ export type WorkerGateQueryResult = ({ readonly ok: true } & WorkerGateAnswer) | { readonly ok: false; readonly detail: string };
52
+
53
+ export interface WorkerGateHookInput {
54
+ readonly env: Readonly<Record<string, string | undefined>>;
55
+ /** Read only in a Wolfpack worker session. */
56
+ readStdin(): string;
57
+ readonly timeoutMs?: number;
58
+ }
59
+
60
+ export interface WorkerGateHookResult {
61
+ /** 0 allows; 2 blocks the tool call and shows `stderr` to the model. */
62
+ readonly exitCode: 0 | 2;
63
+ readonly stderr: string;
64
+ }
65
+
66
+ const ALLOW: WorkerGateHookResult = Object.freeze({ exitCode: 0, stderr: "" });
67
+
68
+ function bound(text: string, max: number): string {
69
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
70
+ }
71
+
72
+ function deny(reason: string): WorkerGateHookResult {
73
+ const text = reason.startsWith(WORKER_GATE_DENIAL_CODE) ? reason : `${WORKER_GATE_DENIAL_CODE}: ${reason}`;
74
+ return { exitCode: 2, stderr: bound(text, WORKER_GATE_REASON_MAX_CHARS) };
75
+ }
76
+
77
+ const blocked = (detail: string): WorkerGateHookResult => deny(`native tools are blocked: ${detail}; call agent_task_inbox for an assignment, or check that the wolfpack-tasks MCP server is running`);
78
+
79
+ /** Decides one `PreToolUse` call. Outside a Wolfpack worker session it allows without reading input or contacting a server. */
80
+ export async function decideWorkerGateHook(input: WorkerGateHookInput): Promise<WorkerGateHookResult> {
81
+ if (!taskWorkerModeEnabled(input.env)) return ALLOW;
82
+ const environment = readClaudeEnvironment(input.env);
83
+ if (environment.kind === "inactive") return ALLOW;
84
+ if (environment.kind === "misconfigured") return blocked(`wolfpack tasks misconfigured: ${environment.variable} ${environment.reason}`);
85
+
86
+ let toolName: unknown;
87
+ try { toolName = (JSON.parse(input.readStdin()) as { tool_name?: unknown } | null)?.tool_name; } catch { /* handled below */ }
88
+ if (typeof toolName !== "string" || toolName === "") return blocked("hook input is unreadable");
89
+ // Own tools are gated inside the MCP server with their validated input.
90
+ if (toolName.startsWith(CLAUDE_PLUGIN.toolPrefix) || HARNESS_PRE_ASSIGNMENT_TOOLS.has(toolName)) return ALLOW;
91
+
92
+ const path = environment.gateSocketPath;
93
+ if (!socketPathFits(path)) return blocked(`task server socket path is longer than ${UNIX_SOCKET_PATH_MAX_BYTES} bytes: ${path}`);
94
+ const answer = await queryWorkerGate(path, { sessionId: environment.sessionId, toolName }, input.timeoutMs ?? WORKER_GATE_TIMEOUT_MS);
95
+ if (!answer.ok) return blocked(answer.detail);
96
+ if (!answer.authorized) return deny(answer.reason ?? WORKER_GATE_DENIAL_CODE);
97
+ return ALLOW;
98
+ }
99
+
100
+ /** Asks the server at `path` once. Every failure, including no answer within `timeoutMs`, is a result, never a throw. */
101
+ export function queryWorkerGate(path: string, request: { readonly sessionId: string; readonly toolName: string }, timeoutMs = WORKER_GATE_TIMEOUT_MS): Promise<WorkerGateQueryResult> {
102
+ return new Promise((resolve) => {
103
+ let settled = false;
104
+ let received = Buffer.alloc(0);
105
+ const socket = createConnection(path);
106
+ const finish = (result: WorkerGateQueryResult): void => {
107
+ if (settled) return;
108
+ settled = true;
109
+ clearTimeout(timer);
110
+ socket.destroy();
111
+ resolve(result);
112
+ };
113
+ const failed = (detail: string): void => finish({ ok: false, detail });
114
+ const timer = setTimeout(() => failed(`task server did not answer within ${timeoutMs} ms`), timeoutMs);
115
+ socket.on("connect", () => {
116
+ socket.write(`${JSON.stringify({ v: WORKER_GATE_PROTOCOL_VERSION, session: request.sessionId, tool: request.toolName.slice(0, TOOL_NAME_MAX_CHARS) })}\n`);
117
+ });
118
+ socket.on("data", (chunk: Buffer) => {
119
+ received = Buffer.concat([received, chunk]);
120
+ const end = received.indexOf(0x0a);
121
+ if (end > WORKER_GATE_REPLY_MAX_BYTES || (end < 0 && received.length > WORKER_GATE_REPLY_MAX_BYTES)) { failed(`task server sent an oversized answer (more than ${WORKER_GATE_REPLY_MAX_BYTES} bytes)`); return; }
122
+ if (end >= 0) finish(parseReply(received.subarray(0, end).toString("utf8"), request.sessionId));
123
+ });
124
+ socket.on("end", () => failed("task server closed the connection without an answer"));
125
+ socket.on("close", () => failed("task server closed the connection without an answer"));
126
+ socket.on("error", (error: NodeJS.ErrnoException) => failed(connectionFailure(error)));
127
+ });
128
+ }
129
+
130
+ function connectionFailure(error: NodeJS.ErrnoException): string {
131
+ if (error.code === "ENOENT") return "task server socket is missing (the server is not running)";
132
+ if (error.code === "ECONNREFUSED") return "task server is not running (connection refused)";
133
+ return `cannot reach the task server (${error.code ?? error.message})`;
134
+ }
135
+
136
+ function parseReply(line: string, sessionId: string): WorkerGateQueryResult {
137
+ let reply: unknown;
138
+ try { reply = JSON.parse(line); } catch { return { ok: false, detail: "task server sent a malformed answer" }; }
139
+ if (typeof reply !== "object" || reply === null) return { ok: false, detail: "task server sent a malformed answer" };
140
+ const fields = reply as Record<string, unknown>;
141
+ if (fields.v !== WORKER_GATE_PROTOCOL_VERSION) return { ok: false, detail: `task server speaks protocol version ${bound(JSON.stringify(fields.v) ?? "none", 32)}, expected ${WORKER_GATE_PROTOCOL_VERSION}` };
142
+ if (fields.session !== sessionId) return { ok: false, detail: "task server answered for another session" };
143
+ if (typeof fields.authorized !== "boolean" || !(fields.reason === null || typeof fields.reason === "string")) return { ok: false, detail: "task server sent a malformed answer" };
144
+ return { ok: true, authorized: fields.authorized, reason: fields.reason };
145
+ }
146
+
147
+ export interface WorkerGateListener {
148
+ /** Stops answering, ends open connections, and unlinks the socket unless a newer listener replaced it. */
149
+ close(): Promise<void>;
150
+ }
151
+
152
+ export interface WorkerGateListenOptions {
153
+ readonly path: string;
154
+ readonly sessionId: string;
155
+ /** The server's current gate decision; a rejection answers with a denial carrying its message. */
156
+ answer(): Promise<WorkerGateAnswer>;
157
+ log(message: string): void;
158
+ /** Defaults to `WORKER_GATE_MAX_CONNECTIONS`. */
159
+ readonly maxConnections?: number;
160
+ /** Defaults to `WORKER_GATE_REQUEST_DEADLINE_MS`. */
161
+ readonly requestDeadlineMs?: number;
162
+ }
163
+
164
+ /**
165
+ * Listens for gate requests at `path` in a mode-0700 directory. Returns undefined, after logging why, when it cannot
166
+ * listen; the hook then denies. The socket is bound at a temporary name and renamed into place: the rename replaces a
167
+ * stale socket atomically, and closing unlinks only the temporary name (Node and Bun unlink a unix socket's bound path
168
+ * on close), so a closing listener cannot remove a newer listener's socket.
169
+ */
170
+ export async function listenWorkerGate(options: WorkerGateListenOptions): Promise<WorkerGateListener | undefined> {
171
+ const { path, sessionId, log } = options;
172
+ const failed = (reason: string): undefined => { log(`wolfpack-tasks: worker gate is not listening, native tools stay blocked: ${reason}`); return undefined; };
173
+ if (!socketPathFits(path)) return failed(`socket path is longer than ${UNIX_SOCKET_PATH_MAX_BYTES} bytes: ${path}`);
174
+ const directory = dirname(path);
175
+ try {
176
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
177
+ chmodSync(directory, 0o700);
178
+ } catch (error) {
179
+ return failed(`cannot prepare ${directory}: ${message(error)}`);
180
+ }
181
+ try {
182
+ if (!lstatSync(path).isSocket()) return failed(`${path} exists and is not a socket`);
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return failed(`cannot inspect ${path}: ${message(error)}`);
185
+ }
186
+
187
+ const maxConnections = options.maxConnections ?? WORKER_GATE_MAX_CONNECTIONS;
188
+ const requestDeadlineMs = options.requestDeadlineMs ?? WORKER_GATE_REQUEST_DEADLINE_MS;
189
+ const sockets = new Set<Socket>();
190
+ const server = createServer((socket) => {
191
+ socket.on("error", () => undefined);
192
+ // Admission: a slow or hostile client cannot hold more than the cap; the hook denies a rejected query.
193
+ if (sockets.size >= maxConnections) { socket.destroy(); return; }
194
+ sockets.add(socket);
195
+ // Absolute from accept, unlike an idle timeout, so trickling bytes cannot extend it.
196
+ const deadline = setTimeout(() => socket.destroy(), requestDeadlineMs);
197
+ socket.on("close", () => { clearTimeout(deadline); sockets.delete(socket); });
198
+ let received = Buffer.alloc(0);
199
+ let handled = false;
200
+ socket.on("data", (chunk: Buffer) => {
201
+ if (handled) return;
202
+ received = Buffer.concat([received, chunk]);
203
+ const end = received.indexOf(0x0a);
204
+ if (end > WORKER_GATE_REQUEST_MAX_BYTES || (end < 0 && received.length > WORKER_GATE_REQUEST_MAX_BYTES)) { handled = true; socket.destroy(); return; }
205
+ if (end < 0) return;
206
+ handled = true;
207
+ void respond(received.subarray(0, end).toString("utf8")).then((answer) => {
208
+ if (socket.destroyed) return;
209
+ const reason = answer.reason === null ? null : bound(answer.reason, WORKER_GATE_REASON_MAX_CHARS);
210
+ socket.end(`${JSON.stringify({ v: WORKER_GATE_PROTOCOL_VERSION, session: sessionId, authorized: answer.authorized, reason })}\n`);
211
+ });
212
+ });
213
+ });
214
+ const respond = async (line: string): Promise<WorkerGateAnswer> => {
215
+ let request: unknown;
216
+ try { request = JSON.parse(line); } catch { return refuse("malformed request"); }
217
+ if (typeof request !== "object" || request === null) return refuse("malformed request");
218
+ const fields = request as Record<string, unknown>;
219
+ if (fields.v !== WORKER_GATE_PROTOCOL_VERSION) return refuse(`unsupported protocol version ${bound(JSON.stringify(fields.v) ?? "none", 32)}`);
220
+ if (fields.session !== sessionId) return refuse("request for another session");
221
+ if (typeof fields.tool !== "string") return refuse("malformed request");
222
+ try { return await options.answer(); } catch (error) { return refuse(`evaluation failed: ${message(error)}`); }
223
+ };
224
+
225
+ const temporary = join(directory, `${randomBytes(8).toString("hex")}.tmp`);
226
+ try {
227
+ await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(temporary, () => { server.off("error", reject); resolve(); }); });
228
+ chmodSync(temporary, 0o600);
229
+ renameSync(temporary, path);
230
+ } catch (error) {
231
+ await closeServer(server, sockets);
232
+ try { rmSync(temporary, { force: true }); } catch { /* best effort */ }
233
+ return failed(`cannot listen at ${path}: ${message(error)}`);
234
+ }
235
+ const inode = lstatSync(path).ino;
236
+ server.on("error", (error) => log(`wolfpack-tasks: worker gate socket error: ${message(error)}`));
237
+
238
+ let closing: Promise<void> | undefined;
239
+ return {
240
+ close() {
241
+ closing ??= (async () => {
242
+ // Unlink first so new hooks see a missing socket, but only our own: a newer listener may have replaced it.
243
+ try { if (lstatSync(path).ino === inode) unlinkSync(path); } catch { /* already gone */ }
244
+ await closeServer(server, sockets);
245
+ })();
246
+ return closing;
247
+ },
248
+ };
249
+ }
250
+
251
+ function closeServer(server: ReturnType<typeof createServer>, sockets: Set<Socket>): Promise<void> {
252
+ return new Promise((resolve) => {
253
+ for (const socket of sockets) socket.destroy();
254
+ if (!server.listening) { resolve(); return; }
255
+ server.close(() => resolve());
256
+ });
257
+ }
258
+
259
+ const refuse = (reason: string): WorkerGateAnswer => ({ authorized: false, reason: `${WORKER_GATE_DENIAL_CODE}: worker gate ${reason}` });
260
+ const message = (error: unknown): string => error instanceof Error ? error.message : String(error);