@staix/agent-hub 0.12.30 → 0.12.31
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/CHANGELOG.md +21 -0
- package/README.md +21 -7
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +4 -0
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +5 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +129 -22
- package/docs/quickstart.md +17 -2
- package/docs/security.md +30 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +66 -19
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +56 -12
- package/src/cli/console.ts +19 -6
- package/src/cli/identity.ts +3 -2
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +8 -1
- package/src/cli/output.ts +13 -2
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +162 -58
- package/src/cli/upgrade.ts +49 -24
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +78 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +400 -67
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +173 -34
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { constants, closeSync, fstatSync, openSync, readFileSync } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
7
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
8
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
9
|
+
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
10
|
+
|
|
11
|
+
export const CHECK_IMAGE = "oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895";
|
|
12
|
+
const NETWORK = "agenthub-check";
|
|
13
|
+
const DAY = 86_400_000;
|
|
14
|
+
const MAX_OUTPUT = 1_048_576;
|
|
15
|
+
export interface RunnerState { available: boolean; reason?: string; platform?: string; checkedAt?: number }
|
|
16
|
+
export interface CheckRequest {
|
|
17
|
+
task: number; owner: string; generation: number | string; updated: number | string;
|
|
18
|
+
/** Internal board history revision, never part of an agent receipt projection. */
|
|
19
|
+
revision?: number;
|
|
20
|
+
ref: string; check: "test" | "typecheck"; files?: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface RunReceipt extends CheckRequest {
|
|
23
|
+
run: string; sha: string; tree: string; lockfile: string; excluded: number; dependency_files: number;
|
|
24
|
+
runner: "container"; image: string; platform: string; container: string;
|
|
25
|
+
hub_pid: number; hub_signature: string; created: number;
|
|
26
|
+
status: "queued" | "running" | "finished" | "stale";
|
|
27
|
+
exit: number | null; timed_out: boolean; cancelled: boolean; interrupted: boolean;
|
|
28
|
+
duration_ms: number; tail: string; output_bytes: number; truncated: boolean; output_withheld: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RunHandle { run: string; done: Promise<RunReceipt> }
|
|
32
|
+
export interface CommandResult { code: number | null; stdout: string }
|
|
33
|
+
/** Fixed-argv transport seam. The production implementation below is the only container spawner. */
|
|
34
|
+
export type ContainerCommand = (args: string[], options?: { signal?: AbortSignal; timeoutMs?: number; output?: (data: Buffer) => void }) => Promise<CommandResult>;
|
|
35
|
+
export interface RunnerOptions {
|
|
36
|
+
projectDir: string; stateDir: string; storageDir: string; projectKey: string;
|
|
37
|
+
enabled: boolean | (() => boolean); isDenied: (path: string) => boolean;
|
|
38
|
+
onResult?: (receipt: RunReceipt) => void | Promise<void>;
|
|
39
|
+
/** Daemon authority fence, re-evaluated across every asynchronous stage. */
|
|
40
|
+
isCurrent?: (request: CheckRequest) => boolean;
|
|
41
|
+
/** Test seams; never supplied from a tool request or config. */
|
|
42
|
+
command?: ContainerCommand; timeoutMs?: number; lockWaitMs?: number; lockPollMs?: number;
|
|
43
|
+
schedule?: (callback: () => void, ms: number) => () => void;
|
|
44
|
+
now?: () => number; liveness?: typeof processLiveness; signature?: typeof processSignature;
|
|
45
|
+
}
|
|
46
|
+
interface Entry { receipt: RunReceipt; resolve: (receipt: RunReceipt) => void; abort: AbortController }
|
|
47
|
+
function safePath(path: string): boolean {
|
|
48
|
+
return !!path && !isAbsolute(path) && !path.includes("\\") && !path.includes("\0") && path.split("/").every(p => p !== ".." && p !== "." && p !== "");
|
|
49
|
+
}
|
|
50
|
+
function foldedPath(path: string): string {
|
|
51
|
+
let folded = path.normalize("NFC");
|
|
52
|
+
// Reach a fixed point: capital sharp s first lowers to sharp s, then expands to ss on the next pass.
|
|
53
|
+
// Fail closed if a future Unicode mapping does not stabilize within this conservative finite bound.
|
|
54
|
+
for (let step = 0; step < 8; step++) {
|
|
55
|
+
const next = folded.toUpperCase().toLowerCase().normalize("NFC");
|
|
56
|
+
if (next === folded) return folded;
|
|
57
|
+
folded = next;
|
|
58
|
+
}
|
|
59
|
+
throw new Error("source path folding unavailable");
|
|
60
|
+
}
|
|
61
|
+
function hash(data: Uint8Array): string { return createHash("sha256").update(data).digest("hex"); }
|
|
62
|
+
function isolatedNetwork(stdout: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
const value: unknown = JSON.parse(stdout);
|
|
65
|
+
return Array.isArray(value) && value.length === 1 && value[0]?.id === NETWORK && value[0]?.configuration?.name === NETWORK && value[0]?.configuration?.mode === "hostOnly";
|
|
66
|
+
} catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
function tail(text: string, truncated = false): string {
|
|
69
|
+
const lines = text.trimEnd().split("\n");
|
|
70
|
+
let result = lines.slice(-40).join("\n");
|
|
71
|
+
const cut = truncated || lines.length > 40 || Buffer.byteLength(result) > 4096;
|
|
72
|
+
while (Buffer.byteLength(result) > (cut ? 4090 : 4096)) result = result.slice(1);
|
|
73
|
+
return cut ? `[cut]\n${result}` : result;
|
|
74
|
+
}
|
|
75
|
+
function runContainer(executable: string, args: string[], options: { signal?: AbortSignal; timeoutMs?: number; output?: (data: Buffer) => void } = {}): Promise<CommandResult> {
|
|
76
|
+
return new Promise((resolveResult, reject) => {
|
|
77
|
+
// No host shell or inherited agent credentials. Only the container client connects to its service.
|
|
78
|
+
const env = { PATH: dirname(executable), HOME: process.env.HOME, TMPDIR: process.env.TMPDIR };
|
|
79
|
+
const child = spawn(executable, args, { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
80
|
+
let stdout = "";
|
|
81
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), Math.max(1, Math.min(600_000, options.timeoutMs ?? 15_000)));
|
|
82
|
+
const abort = () => child.kill("SIGKILL");
|
|
83
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
84
|
+
if (options.signal?.aborted) abort();
|
|
85
|
+
child.stdout.on("data", (data: Buffer) => { if (options.output) options.output(data); else stdout = (stdout + data.toString()).slice(-1_048_576); });
|
|
86
|
+
child.stderr.on("data", (data: Buffer) => { options.output?.(data); });
|
|
87
|
+
child.once("error", () => { clearTimeout(timer); options.signal?.removeEventListener("abort", abort); reject(new Error("container unavailable")); });
|
|
88
|
+
child.once("close", code => { clearTimeout(timer); options.signal?.removeEventListener("abort", abort); resolveResult({ code, stdout }); });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Hub-owned immutable source checks. Authority and current-policy output screening stay in the daemon. */
|
|
93
|
+
export class ContainerRunner {
|
|
94
|
+
private readonly command: ContainerCommand;
|
|
95
|
+
private readonly entries = new Map<string, Entry>();
|
|
96
|
+
private readonly queue: Entry[] = [];
|
|
97
|
+
private active?: Entry;
|
|
98
|
+
private closing = false;
|
|
99
|
+
private readonly unknownReceiptIds = new Set<string>();
|
|
100
|
+
private maintenanceFailed = false;
|
|
101
|
+
get unknownReceipts(): readonly string[] { return [...this.unknownReceiptIds].sort(); }
|
|
102
|
+
get maintenanceUnavailable(): boolean { return this.maintenanceFailed; }
|
|
103
|
+
private readonly probes = new Map<AbortController, Promise<RunnerState>>();
|
|
104
|
+
private availableCache?: { at: number; value: RunnerState };
|
|
105
|
+
private readonly now: () => number;
|
|
106
|
+
private readonly signature: string;
|
|
107
|
+
private common = "";
|
|
108
|
+
private readonly records: string;
|
|
109
|
+
private readonly root: string;
|
|
110
|
+
private readonly retentionTimer: ReturnType<typeof setInterval>;
|
|
111
|
+
constructor(private readonly options: RunnerOptions) {
|
|
112
|
+
if (!/^[A-Za-z0-9_-]+$/.test(options.projectKey)) throw new Error("invalid project key");
|
|
113
|
+
this.command = options.command ?? (async (args, opts) => runContainer(hostExecutable("container", options.projectDir), args, opts));
|
|
114
|
+
this.now = options.now ?? Date.now;
|
|
115
|
+
this.signature = (options.signature ?? processSignature)(process.pid) ?? "";
|
|
116
|
+
this.records = join(options.stateDir, "checks");
|
|
117
|
+
this.root = join(options.storageDir, options.projectKey);
|
|
118
|
+
this.retentionTimer = setInterval(() => { void this.maintain(); }, 60_000);
|
|
119
|
+
this.retentionTimer.unref();
|
|
120
|
+
}
|
|
121
|
+
private gitEnv(): NodeJS.ProcessEnv {
|
|
122
|
+
return hubGitEnv({ ...process.env, PATH: dirname(hostExecutable("git", this.options.projectDir)) }, { repositoryIsolation: true });
|
|
123
|
+
}
|
|
124
|
+
private git(args: string[]): string {
|
|
125
|
+
const result = hubGitSync(["-C", this.options.projectDir, ...args], { encoding: "utf8", env: this.gitEnv(), maxBuffer: 64 * 1024 * 1024, timeout: 15_000 });
|
|
126
|
+
if (result.status !== 0 || result.error) throw new Error("source unavailable");
|
|
127
|
+
return result.stdout;
|
|
128
|
+
}
|
|
129
|
+
private enabled(): boolean { return typeof this.options.enabled === "function" ? this.options.enabled() : this.options.enabled; }
|
|
130
|
+
async available(signal?: AbortSignal): Promise<RunnerState> {
|
|
131
|
+
if (this.closing) return { available: false, reason: "stopping" };
|
|
132
|
+
if (!this.enabled()) return { available: false, reason: "not enabled" };
|
|
133
|
+
if (!this.signature) return { available: false, reason: "hub identity unavailable" };
|
|
134
|
+
if (!this.common) {
|
|
135
|
+
try { this.common = this.git(["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim(); } catch { return { available: false, reason: "git source unavailable" }; }
|
|
136
|
+
}
|
|
137
|
+
if (this.availableCache && this.now() - this.availableCache.at < 60_000) return this.availableCache.value;
|
|
138
|
+
const abort = new AbortController(), cancelled = () => abort.abort();
|
|
139
|
+
signal?.addEventListener("abort", cancelled, { once: true });
|
|
140
|
+
if (signal?.aborted) abort.abort();
|
|
141
|
+
const pending = Promise.resolve().then(() => this.probeAvailability(abort.signal)).finally(() => {
|
|
142
|
+
signal?.removeEventListener("abort", cancelled); this.probes.delete(abort);
|
|
143
|
+
});
|
|
144
|
+
this.probes.set(abort, pending);
|
|
145
|
+
return pending;
|
|
146
|
+
}
|
|
147
|
+
private async probeAvailability(signal: AbortSignal): Promise<RunnerState> {
|
|
148
|
+
let value: RunnerState;
|
|
149
|
+
try {
|
|
150
|
+
const probe = async (args: string[]) => {
|
|
151
|
+
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
152
|
+
const result = await this.command(args, { signal, timeoutMs: 15_000 });
|
|
153
|
+
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
154
|
+
return result;
|
|
155
|
+
};
|
|
156
|
+
const version = await probe(["--version"]);
|
|
157
|
+
const service = await probe(["system", "status"]);
|
|
158
|
+
const image = await probe(["image", "inspect", CHECK_IMAGE]);
|
|
159
|
+
const network = await probe(["network", "inspect", NETWORK]);
|
|
160
|
+
value = version.code !== 0 || service.code !== 0 || image.code !== 0
|
|
161
|
+
? { available: false, reason: "service or pinned image unavailable" }
|
|
162
|
+
: network.code !== 0 && network.code !== 1
|
|
163
|
+
? { available: false, reason: "network inspection unavailable" }
|
|
164
|
+
: network.code === 0 && !isolatedNetwork(network.stdout)
|
|
165
|
+
? { available: false, reason: "network isolation unverified" }
|
|
166
|
+
: { available: true, platform: `linux/arm64 (apple-container ${version.stdout.trim().slice(0, 80)})` };
|
|
167
|
+
} catch { if (this.closing || signal.aborted) return { available: false, reason: this.closing ? "stopping" : "check stopped" }; value = { available: false, reason: "container unavailable" }; }
|
|
168
|
+
const checkedAt = this.now();
|
|
169
|
+
value.checkedAt = checkedAt;
|
|
170
|
+
this.availableCache = { at: checkedAt, value };
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
start(request: CheckRequest): RunHandle {
|
|
174
|
+
if (this.closing) throw new PreEffectToolRefusal("runner unavailable: stopping");
|
|
175
|
+
if (!this.enabled()) throw new PreEffectToolRefusal("runner unavailable: not enabled");
|
|
176
|
+
if (!Number.isSafeInteger(request.task) || request.task <= 0 || !request.owner || !["test", "typecheck"].includes(request.check) || typeof request.ref !== "string" || !request.ref || request.ref.startsWith("-")) throw new PreEffectToolRefusal("invalid check request");
|
|
177
|
+
if (request.check === "test" && (!Array.isArray(request.files) || request.files.length < 1 || request.files.length > 32 || request.files.some(p => typeof p !== "string" || !/^test\/[A-Za-z0-9._/-]+\.test\.ts$/.test(p) || !safePath(p)))) throw new PreEffectToolRefusal("invalid test files");
|
|
178
|
+
if (request.check === "typecheck" && request.files?.length) throw new PreEffectToolRefusal("typecheck takes no files");
|
|
179
|
+
if (this.entries.size >= 3) throw new PreEffectToolRefusal("busy");
|
|
180
|
+
const run = randomUUID();
|
|
181
|
+
const receipt: RunReceipt = { ...request, files: request.files ? [...request.files] : undefined, run, sha: "", tree: "", lockfile: "", excluded: 0, dependency_files: 0,
|
|
182
|
+
runner: "container", image: CHECK_IMAGE, platform: "linux/arm64 (apple-container unknown)", container: `agenthub-check-${run}`, hub_pid: process.pid, hub_signature: this.signature, created: this.now(), status: "queued", exit: null,
|
|
183
|
+
timed_out: false, cancelled: false, interrupted: false, duration_ms: 0, tail: "", output_bytes: 0, truncated: false, output_withheld: false };
|
|
184
|
+
let resolveResult!: (receipt: RunReceipt) => void;
|
|
185
|
+
const done = new Promise<RunReceipt>(r => { resolveResult = r; });
|
|
186
|
+
const entry = { receipt, resolve: resolveResult, abort: new AbortController() };
|
|
187
|
+
this.entries.set(run, entry); this.queue.push(entry);
|
|
188
|
+
void this.drain();
|
|
189
|
+
return { run, done };
|
|
190
|
+
}
|
|
191
|
+
get activeRun(): string | undefined { return this.active?.receipt.run; }
|
|
192
|
+
get queuedRuns(): readonly string[] { return this.queue.map(entry => entry.receipt.run); }
|
|
193
|
+
private current(request: CheckRequest): boolean {
|
|
194
|
+
try { return this.options.isCurrent?.(request) !== false; } catch { return false; }
|
|
195
|
+
}
|
|
196
|
+
private ensureCurrent(entry: Entry): void {
|
|
197
|
+
if (!this.current(entry.receipt)) { entry.receipt.status = "stale"; entry.abort.abort(); throw new Error("check binding stale"); }
|
|
198
|
+
}
|
|
199
|
+
async markStale(run: string): Promise<void> {
|
|
200
|
+
const entry = this.entries.get(run);
|
|
201
|
+
const receipt = entry?.receipt ?? await this.result(run);
|
|
202
|
+
if (!receipt) return;
|
|
203
|
+
receipt.status = "stale";
|
|
204
|
+
entry?.abort.abort();
|
|
205
|
+
await this.save(receipt);
|
|
206
|
+
if (entry === this.active && entry) await this.cleanup(receipt.container);
|
|
207
|
+
}
|
|
208
|
+
private async save(receipt: RunReceipt): Promise<void> {
|
|
209
|
+
await mkdir(this.records, { recursive: true, mode: 0o700 });
|
|
210
|
+
const path = join(this.records, `${receipt.run}.json`);
|
|
211
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
212
|
+
await writeFile(temp, JSON.stringify(receipt), { mode: 0o600 }); await rename(temp, path);
|
|
213
|
+
}
|
|
214
|
+
private async drain(): Promise<void> {
|
|
215
|
+
if (this.active) return;
|
|
216
|
+
const entry = this.queue.shift(); if (!entry) return;
|
|
217
|
+
this.active = entry;
|
|
218
|
+
try { await this.execute(entry); }
|
|
219
|
+
finally { this.active = undefined; this.entries.delete(entry.receipt.run); entry.resolve({ ...entry.receipt }); void this.drain(); }
|
|
220
|
+
}
|
|
221
|
+
private async heavy(entry: Entry): Promise<() => Promise<void>> {
|
|
222
|
+
const lock = join(this.common, "ahub-heavy.lock");
|
|
223
|
+
const owner = `${process.pid} check-run ${entry.receipt.run} since ${this.now()} in ${this.options.projectDir}`;
|
|
224
|
+
const until = Date.now() + (this.options.lockWaitMs ?? 1_800_000);
|
|
225
|
+
while (true) {
|
|
226
|
+
this.ensureCurrent(entry);
|
|
227
|
+
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
228
|
+
try {
|
|
229
|
+
await mkdir(lock);
|
|
230
|
+
try { await writeFile(join(lock, "owner"), owner); } catch (error) { await rm(lock, { recursive: true, force: true }); throw error; }
|
|
231
|
+
return async () => { if (await readFile(join(lock, "owner"), "utf8").catch(() => "") === owner) await rm(lock, { recursive: true, force: true }); };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
234
|
+
}
|
|
235
|
+
const holder = await readFile(join(lock, "owner"), "utf8").catch(() => "");
|
|
236
|
+
const pid = Number(holder.split(" ")[0]);
|
|
237
|
+
if (Number.isSafeInteger(pid) && pid > 0 && (this.options.liveness ?? processLiveness)(pid) === "gone") {
|
|
238
|
+
// check.sh uses the same conservative pid-only legacy lock protocol.
|
|
239
|
+
if (await readFile(join(lock, "owner"), "utf8").catch(() => "") === holder) await rm(lock, { recursive: true, force: true });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (Date.now() >= until) throw new Error("heavy slot held by another run");
|
|
243
|
+
await new Promise<void>(r => { const finish = () => { clearTimeout(timer); entry.abort.signal.removeEventListener("abort", finish); r(); }; const timer = setTimeout(finish, this.options.lockPollMs ?? 10_000); entry.abort.signal.addEventListener("abort", finish, { once: true }); });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
private async snapshot(receipt: RunReceipt, src: string, signal: AbortSignal, current: () => void): Promise<void> {
|
|
247
|
+
const check = () => { current(); if (signal.aborted) throw new Error("check stopped"); };
|
|
248
|
+
check();
|
|
249
|
+
receipt.sha = this.git(["rev-parse", "--verify", `${receipt.ref}^{commit}`]).trim();
|
|
250
|
+
if (!/^[a-f0-9]{40,64}$/.test(receipt.sha)) throw new Error("invalid source head");
|
|
251
|
+
receipt.tree = this.git(["rev-parse", `${receipt.sha}^{tree}`]).trim();
|
|
252
|
+
const entries = this.git(["ls-tree", "-r", "-z", receipt.sha]).split("\0").filter(Boolean);
|
|
253
|
+
if (entries.length > 100_000) throw new Error("source exceeds file limit");
|
|
254
|
+
const parsed = entries.map(entry => {
|
|
255
|
+
const match = /^(\d+) (\w+) ([a-f0-9]+)\t([\s\S]+)$/.exec(entry);
|
|
256
|
+
if (!match || !safePath(match[4]!)) throw new Error("invalid source path");
|
|
257
|
+
if (match[1] === "120000" || match[1] === "160000") throw new Error("source has a symlink or submodule");
|
|
258
|
+
if (match[2] !== "blob" || !["100644", "100755"].includes(match[1]!)) throw new Error("unsupported source entry");
|
|
259
|
+
return { mode: match[1]!, oid: match[3]!, path: match[4]! };
|
|
260
|
+
});
|
|
261
|
+
// Refuse lossy staging on case-folding/normalizing hosts, independent of the current host's filesystem.
|
|
262
|
+
// Include every directory prefix and denied entry before creating any staging directory or writing a blob.
|
|
263
|
+
const foldedPaths = new Map<string, { spelling: string; directory: boolean }>();
|
|
264
|
+
for (const entry of parsed) {
|
|
265
|
+
const parts = entry.path.split("/");
|
|
266
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
267
|
+
const spelling = parts.slice(0, i).join("/"), directory = i < parts.length;
|
|
268
|
+
const folded = foldedPath(spelling);
|
|
269
|
+
const prior = foldedPaths.get(folded);
|
|
270
|
+
if (prior && (prior.spelling !== spelling || prior.directory !== directory)) throw new Error("source path collision");
|
|
271
|
+
foldedPaths.set(folded, { spelling, directory });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
let bytes = 0;
|
|
275
|
+
const paths = new Set<string>();
|
|
276
|
+
await mkdir(src, { recursive: true, mode: 0o700 }); check();
|
|
277
|
+
for (const entry of parsed) {
|
|
278
|
+
check();
|
|
279
|
+
if (this.options.isDenied(entry.path)) { receipt.excluded++; continue; }
|
|
280
|
+
if (entry.path === "node_modules" || entry.path.startsWith("node_modules/")) throw new Error("tracked dependencies refused");
|
|
281
|
+
const data = hubGitSync(["-C", this.options.projectDir, "cat-file", "blob", entry.oid], { encoding: "buffer" as "utf8", env: this.gitEnv(), maxBuffer: 64 * 1024 * 1024, timeout: 15_000 });
|
|
282
|
+
if (data.status !== 0 || data.error) throw new Error("source blob unavailable");
|
|
283
|
+
const blob = Buffer.from(data.stdout);
|
|
284
|
+
bytes += blob.length; if (bytes > 512 * 1024 * 1024) throw new Error("source exceeds byte limit");
|
|
285
|
+
const dest = join(src, entry.path); await mkdir(dirname(dest), { recursive: true }); check(); await writeFile(dest, blob, { mode: entry.mode === "100755" ? 0o700 : 0o600 }); check(); paths.add(entry.path);
|
|
286
|
+
}
|
|
287
|
+
if (receipt.files?.some(file => !paths.has(file))) throw new Error("test file unavailable in snapshot");
|
|
288
|
+
if (!paths.has("bun.lock")) throw new Error("lockfile unavailable");
|
|
289
|
+
const lock = await readFile(join(src, "bun.lock")); check();
|
|
290
|
+
const hostLock = await this.regularFile(join(this.options.projectDir, "bun.lock")); check();
|
|
291
|
+
receipt.lockfile = hash(lock);
|
|
292
|
+
if (receipt.lockfile !== hash(hostLock)) throw new Error("lockfile differs; dependencies unavailable");
|
|
293
|
+
const deps = join(this.options.projectDir, "node_modules");
|
|
294
|
+
const dest = join(src, "node_modules");
|
|
295
|
+
let count = 0, size = 0;
|
|
296
|
+
const walk = async (path: string): Promise<void> => {
|
|
297
|
+
check();
|
|
298
|
+
if (++count > 100_000) throw new Error("dependencies exceed file limit");
|
|
299
|
+
if (this.options.isDenied(`node_modules/${path}`)) throw new Error("dependencies contain a denied path");
|
|
300
|
+
const source = join(deps, path), target = join(dest, path);
|
|
301
|
+
const st = await lstat(source); check();
|
|
302
|
+
if (st.isSymbolicLink()) {
|
|
303
|
+
const link = await readlink(source); check();
|
|
304
|
+
const resolved = relative(deps, resolve(dirname(source), link));
|
|
305
|
+
if (isAbsolute(link) || resolved === ".." || resolved.startsWith(`../`) || !resolved) throw new Error("dependencies contain an escaping symlink");
|
|
306
|
+
await symlink(link, target); check();
|
|
307
|
+
} else if (st.isDirectory()) {
|
|
308
|
+
await mkdir(target, { recursive: true }); check();
|
|
309
|
+
for (const name of await readdir(source)) { if (!safePath(name)) throw new Error("invalid dependency path"); await walk(path ? `${path}/${name}` : name); }
|
|
310
|
+
} else if (st.isFile()) {
|
|
311
|
+
size += st.size; if (size > 1024 * 1024 * 1024) throw new Error("dependencies exceed byte limit");
|
|
312
|
+
const data = await this.regularFile(source); check(); await writeFile(target, data, { mode: (st.mode & 0o111) ? 0o700 : 0o600 }); check();
|
|
313
|
+
} else throw new Error("dependencies contain a special file");
|
|
314
|
+
const after = await lstat(source); check();
|
|
315
|
+
if (after.ino !== st.ino || after.dev !== st.dev || after.mtimeMs !== st.mtimeMs || after.size !== st.size) throw new Error("dependencies changed during copy");
|
|
316
|
+
};
|
|
317
|
+
await walk(""); receipt.dependency_files = count;
|
|
318
|
+
if (receipt.lockfile !== hash(await this.regularFile(join(this.options.projectDir, "bun.lock")))) throw new Error("lockfile changed during copy");
|
|
319
|
+
}
|
|
320
|
+
private async regularFile(path: string): Promise<Buffer> {
|
|
321
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
322
|
+
try {
|
|
323
|
+
const stat = fstatSync(fd); if (!stat.isFile() || stat.size > 64 * 1024 * 1024) throw new Error("unsafe dependency file");
|
|
324
|
+
const data = readFileSync(fd); const after = fstatSync(fd);
|
|
325
|
+
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || data.length !== stat.size) throw new Error("dependency file changed during copy");
|
|
326
|
+
return data;
|
|
327
|
+
} finally { closeSync(fd); }
|
|
328
|
+
}
|
|
329
|
+
private async execute(entry: Entry): Promise<void> {
|
|
330
|
+
const r = entry.receipt, begun = this.now(), dir = join(this.root, r.run), src = join(dir, "src");
|
|
331
|
+
let release: (() => Promise<void>) | undefined, cancelDeadline: (() => void) | undefined;
|
|
332
|
+
let output = Buffer.alloc(0), outputTail = Buffer.alloc(0), attemptedContainer = false;
|
|
333
|
+
try {
|
|
334
|
+
this.ensureCurrent(entry);
|
|
335
|
+
await this.save(r); this.ensureCurrent(entry);
|
|
336
|
+
const state = await this.available(entry.abort.signal); this.ensureCurrent(entry); if (!state.available) throw new Error(`runner unavailable: ${state.reason}`);
|
|
337
|
+
r.platform = state.platform!;
|
|
338
|
+
release = await this.heavy(entry); this.ensureCurrent(entry);
|
|
339
|
+
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
340
|
+
const timeout = Math.max(1, Math.min(600_000, this.options.timeoutMs ?? 600_000)), deadline = this.now() + timeout;
|
|
341
|
+
const expire = () => { r.timed_out = true; entry.abort.abort(); void this.cleanup(r.container); };
|
|
342
|
+
const schedule = this.options.schedule ?? ((callback: () => void, ms: number) => { const timer = setTimeout(callback, ms); return () => clearTimeout(timer); });
|
|
343
|
+
cancelDeadline = schedule(expire, timeout);
|
|
344
|
+
const checkRunning = () => {
|
|
345
|
+
this.ensureCurrent(entry);
|
|
346
|
+
if (!entry.abort.signal.aborted && this.now() >= deadline) expire();
|
|
347
|
+
if (entry.abort.signal.aborted) throw new Error("check stopped");
|
|
348
|
+
};
|
|
349
|
+
const command = async (args: string[], limit = 15_000, output?: (data: Buffer) => void) => {
|
|
350
|
+
checkRunning();
|
|
351
|
+
const result = await this.command(args, { signal: entry.abort.signal, timeoutMs: Math.min(limit, deadline - this.now()), ...(output ? { output } : {}) });
|
|
352
|
+
checkRunning(); // A transport may return success after cancellation; it cannot authorize another stage.
|
|
353
|
+
return result;
|
|
354
|
+
};
|
|
355
|
+
await this.snapshot(r, src, entry.abort.signal, checkRunning);
|
|
356
|
+
this.ensureCurrent(entry);
|
|
357
|
+
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
358
|
+
const network = await command(["network", "inspect", NETWORK]); this.ensureCurrent(entry);
|
|
359
|
+
if (network.code !== 0 && (await command(["network", "create", "--internal", NETWORK])).code !== 0) throw new Error("runner unavailable: internal network unavailable");
|
|
360
|
+
this.ensureCurrent(entry);
|
|
361
|
+
// Refuse an existing non-internal network rather than trusting its name.
|
|
362
|
+
const info = await command(["network", "inspect", NETWORK]); this.ensureCurrent(entry);
|
|
363
|
+
if (info.code !== 0 || !isolatedNetwork(info.stdout)) throw new Error("runner unavailable: network isolation unverified");
|
|
364
|
+
r.status = "running"; await this.save(r); this.ensureCurrent(entry);
|
|
365
|
+
attemptedContainer = true;
|
|
366
|
+
// Cold VM startup and a <=1.5 GiB source/dependency transfer need their own bounded allowances.
|
|
367
|
+
const run = await command(["run", "--detach", "--rm", "--name", r.container, "--label", `agenthub.project=${this.options.projectKey}`, "--label", `agenthub.hub=${process.pid}:${this.signature}`, "--label", `agenthub.run=${r.run}`, "--network", NETWORK, "--no-dns", "--cpus", "2", "--memory", "4g", "--workdir", "/work", CHECK_IMAGE, "sleep", "600"], 120_000);
|
|
368
|
+
this.ensureCurrent(entry);
|
|
369
|
+
if (run.code !== 0) throw new Error("runner unavailable: container start failed");
|
|
370
|
+
const copy = await command(["cp", `${src}/.`, `${r.container}:/work`], 180_000);
|
|
371
|
+
this.ensureCurrent(entry);
|
|
372
|
+
if (copy.code !== 0) throw new Error("container source transfer failed");
|
|
373
|
+
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
374
|
+
const words = r.check === "test" ? ["bun", "test", ...r.files!] : ["bun", "x", "--no-install", "tsc", "--noEmit"];
|
|
375
|
+
const result = await command(["exec", "--workdir", "/work", r.container, ...words], 600_000, data => {
|
|
376
|
+
r.output_bytes += data.length;
|
|
377
|
+
if (output.length < MAX_OUTPUT) output = Buffer.concat([output, data.subarray(0, MAX_OUTPUT - output.length)]);
|
|
378
|
+
outputTail = Buffer.concat([outputTail, data]).subarray(-16_384);
|
|
379
|
+
r.truncated = r.output_bytes > MAX_OUTPUT;
|
|
380
|
+
});
|
|
381
|
+
this.ensureCurrent(entry);
|
|
382
|
+
r.exit = entry.abort.signal.aborted ? null : result.code;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
385
|
+
r.error = entry.abort.signal.aborted ? "check stopped" : code ? "check filesystem or runtime unavailable" : error instanceof Error ? error.message : "check unavailable";
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
cancelDeadline?.();
|
|
389
|
+
// Teardown has separate bounded commands so an expired check can still prove container removal.
|
|
390
|
+
const removed = !attemptedContainer || await this.cleanup(r.container);
|
|
391
|
+
if (!removed) { r.error = "container cleanup unconfirmed; heavy slot retained"; r.exit = null; }
|
|
392
|
+
await rm(src, { recursive: true, force: true }).catch(() => {});
|
|
393
|
+
if (release && removed) await release().catch(() => {});
|
|
394
|
+
r.duration_ms = Math.max(0, this.now() - begun); r.tail = tail(outputTail.toString("utf8"), r.output_bytes > outputTail.length); if (r.status !== "stale") r.status = "finished";
|
|
395
|
+
if (!this.current(r)) { r.status = "stale"; }
|
|
396
|
+
try { await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(join(dir, "output.log"), r.truncated ? Buffer.concat([output.subarray(0, MAX_OUTPUT - 7), Buffer.from("\n[cut]\n")]) : output, { mode: 0o600 }); await this.save(r); } catch { r.error = "check receipt storage unavailable"; r.tail = ""; r.output_withheld = true; }
|
|
397
|
+
await this.maintain(); // Maintenance never changes an outcome already saved for this run.
|
|
398
|
+
if (!this.current(r)) { r.status = "stale"; await this.save(r).catch(() => {}); }
|
|
399
|
+
try { if (r.status !== "stale") await this.options.onResult?.({ ...r }); } catch { /* A notice cannot retry a completed check. */ }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
private async cleanup(name: string): Promise<boolean> {
|
|
403
|
+
await this.command(["kill", name]).catch(() => {});
|
|
404
|
+
await this.command(["rm", name]).catch(() => {});
|
|
405
|
+
try {
|
|
406
|
+
const list = await this.command(["list", "--all", "--format", "json"]);
|
|
407
|
+
if (list.code !== 0) return false;
|
|
408
|
+
const entries: unknown = JSON.parse(list.stdout);
|
|
409
|
+
return Array.isArray(entries) && !entries.some(c => c?.id === name || c?.configuration?.id === name);
|
|
410
|
+
} catch { return false; }
|
|
411
|
+
}
|
|
412
|
+
async result(run: string): Promise<RunReceipt | undefined> {
|
|
413
|
+
if (!/^[a-f0-9-]{36}$/.test(run)) return undefined;
|
|
414
|
+
const live = this.entries.get(run); if (live) return { ...live.receipt };
|
|
415
|
+
try {
|
|
416
|
+
const r = JSON.parse(await readFile(join(this.records, `${run}.json`), "utf8")) as RunReceipt;
|
|
417
|
+
if (!r || r.run !== run || r.container !== `agenthub-check-${run}` || !Number.isFinite(r.created) || !Number.isSafeInteger(r.hub_pid) || r.hub_pid <= 0 || typeof r.hub_signature !== "string" || !["queued", "running", "finished", "stale"].includes(r.status)) throw new Error("invalid receipt");
|
|
418
|
+
return r;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
421
|
+
throw new Error("check receipt unavailable");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async consumeOutput(run: string): Promise<void> { if (/^[a-f0-9-]{36}$/.test(run)) await rm(join(this.root, run, "output.log"), { force: true }); }
|
|
425
|
+
async cancel(run: string): Promise<void> {
|
|
426
|
+
const entry = this.entries.get(run); if (!entry) return;
|
|
427
|
+
entry.receipt.cancelled = true; entry.abort.abort();
|
|
428
|
+
const queued = this.queue.indexOf(entry);
|
|
429
|
+
if (queued >= 0) {
|
|
430
|
+
// No VM or heavy slot exists for a queued run. Settle it independently of the active run.
|
|
431
|
+
this.queue.splice(queued, 1); this.entries.delete(run);
|
|
432
|
+
const receipt = entry.receipt;
|
|
433
|
+
receipt.status = receipt.status === "stale" || !this.current(receipt) ? "stale" : "finished";
|
|
434
|
+
receipt.duration_ms = Math.max(0, this.now() - receipt.created);
|
|
435
|
+
receipt.error = "check cancelled before start";
|
|
436
|
+
try {
|
|
437
|
+
await this.save(receipt);
|
|
438
|
+
if (!this.current(receipt) && receipt.status !== "stale") { receipt.status = "stale"; await this.save(receipt); }
|
|
439
|
+
} catch {
|
|
440
|
+
receipt.error = "check receipt storage unavailable"; receipt.output_withheld = true;
|
|
441
|
+
throw new Error("check receipt storage unavailable");
|
|
442
|
+
} finally { entry.resolve({ ...receipt }); }
|
|
443
|
+
try { if (receipt.status !== "stale") await this.options.onResult?.({ ...receipt }); } catch { /* A saved cancellation is never retried. */ }
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (entry === this.active) await this.cleanup(entry.receipt.container);
|
|
447
|
+
}
|
|
448
|
+
async stopAll(): Promise<void> {
|
|
449
|
+
this.closing = true; clearInterval(this.retentionTimer);
|
|
450
|
+
for (const abort of this.probes.keys()) abort.abort();
|
|
451
|
+
for (const entry of this.entries.values()) { entry.receipt.interrupted = true; entry.abort.abort(); }
|
|
452
|
+
if (this.active) await this.cleanup(this.active.receipt.container);
|
|
453
|
+
await Promise.allSettled([...this.probes.values()]);
|
|
454
|
+
while (this.entries.size) await new Promise(r => setTimeout(r, 5));
|
|
455
|
+
}
|
|
456
|
+
async sweep(): Promise<string[]> {
|
|
457
|
+
const unknown: string[] = [];
|
|
458
|
+
if (!this.enabled()) { await this.maintain(); return unknown; }
|
|
459
|
+
try {
|
|
460
|
+
let containers: unknown;
|
|
461
|
+
try { const list = await this.command(["list", "--all", "--format", "json"]); if (list.code !== 0) return unknown; containers = JSON.parse(list.stdout); } catch { return unknown; }
|
|
462
|
+
if (!Array.isArray(containers)) return unknown;
|
|
463
|
+
for (const container of containers) {
|
|
464
|
+
const c = container as { id?: string; configuration?: { id?: string; labels?: Record<string, string> }; labels?: Record<string, string> };
|
|
465
|
+
const labels = c.configuration?.labels ?? c.labels;
|
|
466
|
+
if (labels?.["agenthub.project"] !== this.options.projectKey) continue;
|
|
467
|
+
const name = c.configuration?.id ?? c.id;
|
|
468
|
+
const run = labels["agenthub.run"];
|
|
469
|
+
if (!name || !run || name !== `agenthub-check-${run}`) { unknown.push("orphan check: unknown owner"); continue; }
|
|
470
|
+
let r: RunReceipt | undefined;
|
|
471
|
+
try { r = await this.result(run); }
|
|
472
|
+
catch { this.unknownReceiptIds.add(/^[a-f0-9-]{36}$/.test(run) ? run : "unidentified"); }
|
|
473
|
+
if (!r || r.container !== name || !r.hub_signature || labels["agenthub.hub"] !== `${r.hub_pid}:${r.hub_signature}`) { unknown.push(`orphan check ${name}: unknown owner`); continue; }
|
|
474
|
+
const liveness = (this.options.liveness ?? processLiveness)(r.hub_pid, r.hub_signature);
|
|
475
|
+
if (liveness === "live") continue;
|
|
476
|
+
if (liveness !== "gone") { unknown.push(`orphan check ${name}: unknown owner`); continue; }
|
|
477
|
+
if (!await this.cleanup(name)) { unknown.push(`orphan check ${name}: cleanup unconfirmed`); continue; }
|
|
478
|
+
r.interrupted = true; if (r.status !== "stale") r.status = "finished"; r.exit = null; r.tail = "";
|
|
479
|
+
await rm(join(this.root, run, "src"), { recursive: true, force: true }); await this.save(r);
|
|
480
|
+
}
|
|
481
|
+
return unknown;
|
|
482
|
+
} finally { await this.maintain(); }
|
|
483
|
+
}
|
|
484
|
+
private async maintain(): Promise<void> {
|
|
485
|
+
try { await this.retain(); this.maintenanceFailed = false; }
|
|
486
|
+
catch { this.maintenanceFailed = true; }
|
|
487
|
+
}
|
|
488
|
+
private async retain(): Promise<void> {
|
|
489
|
+
const names = await readdir(this.records).catch(error => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
|
|
490
|
+
const present = new Set(names.filter(name => name.endsWith(".json")).map(name => name.slice(0, -5)));
|
|
491
|
+
for (const id of this.unknownReceiptIds) if (id === "unidentified" || !present.has(id)) this.unknownReceiptIds.delete(id);
|
|
492
|
+
const receipts: RunReceipt[] = [];
|
|
493
|
+
for (const name of names) {
|
|
494
|
+
if (!name.endsWith(".json")) continue;
|
|
495
|
+
const id = name.slice(0, -5), safeId = /^[a-f0-9-]{36}$/.test(id) ? id : "unidentified";
|
|
496
|
+
if (safeId === "unidentified") { this.unknownReceiptIds.add(safeId); continue; }
|
|
497
|
+
try { const r = await this.result(id); if (r) receipts.push(r); this.unknownReceiptIds.delete(id); }
|
|
498
|
+
catch { this.unknownReceiptIds.add(id); }
|
|
499
|
+
}
|
|
500
|
+
receipts.sort((a, b) => b.created - a.created);
|
|
501
|
+
for (const [index, r] of receipts.entries()) {
|
|
502
|
+
if (this.entries.has(r.run)) continue;
|
|
503
|
+
if (index >= 200) await rm(join(this.records, `${r.run}.json`), { force: true });
|
|
504
|
+
if (this.now() - r.created >= DAY) await rm(join(this.root, r.run), { recursive: true, force: true });
|
|
505
|
+
else if (index >= 20) await this.consumeOutput(r.run);
|
|
506
|
+
}
|
|
507
|
+
// A crash before a receipt rename must not retain an unindexed staging directory forever.
|
|
508
|
+
for (const name of await readdir(this.root).catch(error => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; })) {
|
|
509
|
+
if (!/^[a-f0-9-]{36}$/.test(name) || this.entries.has(name) || this.unknownReceiptIds.has(name)) continue;
|
|
510
|
+
const path = join(this.root, name); const st = await lstat(path);
|
|
511
|
+
if (this.now() - st.mtimeMs >= DAY) await rm(path, { recursive: true, force: true });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
package/src/hub/child-process.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ChildProcess } from "node:child_process";
|
|
|
8
8
|
export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
9
9
|
const env = { ...source };
|
|
10
10
|
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
11
|
+
delete env.AGENTHUB_DELIVERY_ONLY;
|
|
11
12
|
return env;
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -8,6 +8,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
8
8
|
|
|
9
9
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
10
|
export const PROTOCOL = 16;
|
|
11
|
+
export const DAEMON_FEATURES = ["task_cancel"] as const;
|
|
11
12
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
12
13
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
13
14
|
|
|
@@ -30,6 +31,7 @@ export interface ControlDescriptor {
|
|
|
30
31
|
cwd?: string;
|
|
31
32
|
protocol?: number;
|
|
32
33
|
pid?: number;
|
|
34
|
+
features?: readonly string[];
|
|
33
35
|
/** #226: the daemon's processSignature, written into status.json only (not the status reply); absent before 0.12.21. */
|
|
34
36
|
pidSignature?: string;
|
|
35
37
|
}
|
|
@@ -41,7 +43,7 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
|
41
43
|
const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
|
|
42
44
|
if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
|
|
43
45
|
return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
|
|
44
|
-
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid,
|
|
46
|
+
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
|
|
45
47
|
...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
|
|
46
48
|
} catch {
|
|
47
49
|
return undefined;
|
|
@@ -50,6 +52,7 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
|
50
52
|
|
|
51
53
|
/** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
|
|
52
54
|
export class ControlClient {
|
|
55
|
+
features: readonly string[] = [];
|
|
53
56
|
private nextRid = 1;
|
|
54
57
|
private readonly pending = new Map<number, (msg: any) => void>();
|
|
55
58
|
onPush: (msg: any) => void = () => {};
|
|
@@ -103,6 +106,7 @@ export class ControlClient {
|
|
|
103
106
|
(expected.projectRoot && reply.cwd !== expected.projectRoot)) {
|
|
104
107
|
return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
|
|
105
108
|
}
|
|
109
|
+
client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
|
|
106
110
|
clearTimeout(timer);
|
|
107
111
|
resolve(client);
|
|
108
112
|
});
|