@staix/agent-hub 0.12.22 → 0.12.24
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 +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { personEnv } from "./child-process.ts";
|
|
3
|
+
import { openStartTerminal, orcaExecutable, shellQuote, startTerminalWorktree, type CommandRunner } from "../cli/terminal-recovery.ts";
|
|
4
|
+
|
|
5
|
+
/** #269: where a terminal for a hub-made start comes from, or why there is none. */
|
|
6
|
+
export type TerminalOpened = { ok: true; via: string } | { ok: false; why: string; detail?: string };
|
|
7
|
+
export interface TerminalOpener {
|
|
8
|
+
/** Whether a start could open a terminal now; opens nothing. */
|
|
9
|
+
available(): TerminalOpened;
|
|
10
|
+
/** A terminal running `argv`, which the caller assembled from fixed words: nothing here adds to it. */
|
|
11
|
+
open(title: string, argv: readonly string[]): Promise<TerminalOpened>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ponytail: a template command still running after this long is taken to be the terminal itself, so one that fails
|
|
15
|
+
// later is reported as opened; a provider-specific readiness check (as recovery has for Orca) if that ever misleads.
|
|
16
|
+
const SETTLE_MS = 1500;
|
|
17
|
+
const PLACEHOLDER = /\{(command|title|cwd)\}/g;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The providers, in order: the machine-local `terminal.open` command template, then Orca (the path recovery uses)
|
|
21
|
+
* when this project has an Orca worktree. `run` replaces the Orca CLI in tests.
|
|
22
|
+
*/
|
|
23
|
+
export function terminalOpener(opts: { template: string[]; cwd: string; stateDir: string; env?: NodeJS.ProcessEnv; run?: CommandRunner }): TerminalOpener {
|
|
24
|
+
const env = opts.env ?? process.env;
|
|
25
|
+
const worktree = () => startTerminalWorktree(opts.cwd, opts.stateDir, env);
|
|
26
|
+
const available = (): TerminalOpened => {
|
|
27
|
+
if (opts.template.length) return { ok: true, via: "terminal.open" };
|
|
28
|
+
if (!worktree()) return { ok: false, why: "no terminal.open command is configured and this project has no Orca worktree on record" };
|
|
29
|
+
if (!opts.run && !Bun.which(orcaExecutable())) return { ok: false, why: `the ${orcaExecutable()} command is not on PATH` };
|
|
30
|
+
return { ok: true, via: "orca" };
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
available,
|
|
34
|
+
async open(title, argv) {
|
|
35
|
+
const can = available();
|
|
36
|
+
if (!can.ok) return can;
|
|
37
|
+
// One command line, each word quoted for a POSIX shell: the terminal's shell runs exactly these words.
|
|
38
|
+
const command = argv.map(shellQuote).join(" ");
|
|
39
|
+
try {
|
|
40
|
+
if (can.via === "orca") {
|
|
41
|
+
await openStartTerminal(worktree()!, command, title, opts.run ? { runner: opts.run } : undefined);
|
|
42
|
+
return can;
|
|
43
|
+
}
|
|
44
|
+
// One pass with a function: a placeholder inside a substituted value (a project path that contains "{cwd}")
|
|
45
|
+
// is not expanded again, and "$" in a path is not a replacement pattern. Only {command} is shell-quoted;
|
|
46
|
+
// {title} and {cwd} are raw text for an argv element of their own.
|
|
47
|
+
const values: Record<string, string> = { command, title, cwd: opts.cwd };
|
|
48
|
+
const [bin, ...args] = opts.template.map((part) => part.replace(PLACEHOLDER, (_, name: string) => values[name]!));
|
|
49
|
+
// Detached and unwatched: the terminal is the person's and outlives the hub.
|
|
50
|
+
const child = spawn(bin!, args, { cwd: opts.cwd, env: personEnv(env), stdio: "ignore", detached: true });
|
|
51
|
+
const failed = await new Promise<string | undefined>((resolve) => {
|
|
52
|
+
const timer = setTimeout(() => resolve(undefined), SETTLE_MS); // still running: it is the terminal itself
|
|
53
|
+
child.once("error", (error) => { clearTimeout(timer); resolve(`terminal.open could not start (${(error as NodeJS.ErrnoException).code ?? "error"})`); });
|
|
54
|
+
child.once("exit", (code, signal) => { clearTimeout(timer); resolve(code === 0 ? undefined : `terminal.open exited ${signal ?? code}`); });
|
|
55
|
+
});
|
|
56
|
+
child.unref();
|
|
57
|
+
return failed ? { ok: false, why: failed } : can;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// The provider's own text can name paths and its stderr: it goes to the hub's log, not to the caller.
|
|
60
|
+
return { ok: false, why: can.via === "orca" ? "Orca could not create the terminal" : "terminal.open could not be run", detail: (error as Error).message };
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { sbplString } from "../local/deny.ts";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { realPath } from "./project.ts";
|
|
9
|
+
import { profile, sandboxAvailable } from "../local/sandbox.ts";
|
|
10
|
+
export type ProjectionConfig = Record<string, string[]>;
|
|
11
|
+
export const MAX_PROJECTION_INPUT = 16 * 1024 * 1024;
|
|
12
|
+
const MAX_INPUT = MAX_PROJECTION_INPUT, MAX_TEXT = 256 * 1024;
|
|
13
|
+
/** Only machine-local config chooses programs; no implicit shell or command-string parsing. */
|
|
14
|
+
export function projectionConfig(raw: unknown): ProjectionConfig {
|
|
15
|
+
if (raw === undefined) return {};
|
|
16
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 16) throw new Error("text_projection must be a bounded extension-to-argv map");
|
|
17
|
+
const out: ProjectionConfig = Object.create(null);
|
|
18
|
+
for (const [ext, argv] of Object.entries(raw)) {
|
|
19
|
+
if (!/^\.[a-z0-9]{1,16}$/.test(ext) || !Array.isArray(argv) || !argv.length || argv.length > 16 || argv.some(arg => typeof arg !== "string" || !arg || arg.length > 2000 || /[\x00-\x1f\x7f]/.test(arg)) || argv.filter(arg => arg === "{input}").length !== 1 || argv[0] === "{input}") throw new Error("text_projection entries require a bounded argv with one separate {input} argument");
|
|
20
|
+
out[ext] = [...argv];
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export interface ProjectedText { inputHash: string; rawHash: string; rawBlob: string; configHash: string; text: string }
|
|
25
|
+
const hash = (data: string | Buffer) => createHash("sha256").update(data).digest("hex");
|
|
26
|
+
function commandFingerprint(cwd: string, argv: string[]): string {
|
|
27
|
+
const parts = [JSON.stringify(argv)];
|
|
28
|
+
for (const [index, arg] of argv.entries()) {
|
|
29
|
+
if (index !== 0 && !/\.(?:sh|py|js|ts|rb)$/.test(arg)) continue;
|
|
30
|
+
const choices = isAbsolute(arg) ? [arg] : index === 0 && !arg.includes("/") ? (process.env.PATH ?? "/usr/bin:/bin").split(":").slice(0, 32).map(dir => join(dir, arg)) : [resolve(cwd, arg)];
|
|
31
|
+
for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue; parts.push(file, String(st.size), String(st.mtimeMs), String(st.ctimeMs)); break; } catch { /* another PATH entry */ }
|
|
32
|
+
}
|
|
33
|
+
return hash(parts.join("\\n"));
|
|
34
|
+
}
|
|
35
|
+
function boundedCache(file: string): string {
|
|
36
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
37
|
+
try {
|
|
38
|
+
const st = fstatSync(fd);
|
|
39
|
+
if (!st.isFile() || st.nlink !== 1 || st.size > MAX_TEXT * 2) throw new Error("invalid cache file");
|
|
40
|
+
const bytes = Buffer.alloc(st.size); let got = 0;
|
|
41
|
+
while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
|
|
42
|
+
if (got !== st.size) throw new Error("cache changed");
|
|
43
|
+
return bytes.toString("utf8");
|
|
44
|
+
} finally { closeSync(fd); }
|
|
45
|
+
}
|
|
46
|
+
/** Projections are presentation evidence, never proof of who wrote the original bytes. */
|
|
47
|
+
export class TextProjections {
|
|
48
|
+
private readonly sandbox: string;
|
|
49
|
+
private readonly warned = new Set<string>();
|
|
50
|
+
constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
|
|
51
|
+
this.sandbox = Object.keys(options.config).length ? profile(options.cwd, false, options.readAllow, options.deny, ["."]) : "";
|
|
52
|
+
}
|
|
53
|
+
private warn(ext: string): void {
|
|
54
|
+
if (this.warned.has(ext)) return;
|
|
55
|
+
this.warned.add(ext); this.options.log?.("text projection " + ext + " unavailable; retained original facts behavior");
|
|
56
|
+
}
|
|
57
|
+
get enabled(): boolean { return Object.keys(this.options.config).length > 0; }
|
|
58
|
+
handles(file: string): boolean { return !!this.options.config[extname(file).toLowerCase()]; }
|
|
59
|
+
project(file: string): ProjectedText | undefined {
|
|
60
|
+
const ext = extname(file).toLowerCase(), argv = this.options.config[ext];
|
|
61
|
+
if (!argv) return undefined;
|
|
62
|
+
let fd: number | undefined;
|
|
63
|
+
try {
|
|
64
|
+
const root = realPath(this.options.cwd), input = realPath(file);
|
|
65
|
+
if (!input.startsWith(root + "/")) throw new Error("input outside project");
|
|
66
|
+
fd = openSync(input, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
67
|
+
const before = fstatSync(fd);
|
|
68
|
+
if (!before.isFile() || before.size > MAX_INPUT) throw new Error("input cap");
|
|
69
|
+
const bytes = Buffer.alloc(before.size); let got = 0;
|
|
70
|
+
while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
|
|
71
|
+
const after = fstatSync(fd);
|
|
72
|
+
if (got !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs) throw new Error("input changed");
|
|
73
|
+
return this.convert(ext, bytes, () => {
|
|
74
|
+
const final = fstatSync(fd!);
|
|
75
|
+
if (final.size !== before.size || final.mtimeMs !== before.mtimeMs) throw new Error("input changed during conversion");
|
|
76
|
+
});
|
|
77
|
+
} catch { this.warn(ext); return undefined; }
|
|
78
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
79
|
+
}
|
|
80
|
+
/** Historic raw bytes; optional deadline uses performance.now(), shared across the preview. No current file read. */
|
|
81
|
+
projectBytes(file: string, bytes: Buffer, deadline?: number): ProjectedText | undefined {
|
|
82
|
+
const ext = extname(file).toLowerCase();
|
|
83
|
+
if (!this.options.config[ext]) return undefined;
|
|
84
|
+
try {
|
|
85
|
+
const root = realPath(this.options.cwd), input = resolve(root, file);
|
|
86
|
+
if (!input.startsWith(root + "/") || !Buffer.isBuffer(bytes) || bytes.length > MAX_INPUT) throw new Error("snapshot input cap or path");
|
|
87
|
+
return this.convert(ext, Buffer.from(bytes), undefined, deadline);
|
|
88
|
+
} catch { this.warn(ext); return undefined; }
|
|
89
|
+
}
|
|
90
|
+
private convert(ext: string, bytes: Buffer, verify?: () => void, deadline?: number): ProjectedText | undefined {
|
|
91
|
+
const argv = this.options.config[ext];
|
|
92
|
+
if (!argv) return undefined;
|
|
93
|
+
let scratch: string | undefined;
|
|
94
|
+
try {
|
|
95
|
+
if (deadline !== undefined && (!Number.isFinite(deadline) || deadline <= performance.now())) throw new Error("projection deadline");
|
|
96
|
+
const rawHash = createHash("sha1").update(bytes).digest("hex"), rawBlob = createHash("sha1").update("blob " + bytes.length + "\0").update(bytes).digest("hex");
|
|
97
|
+
const inputHash = hash(bytes), configHash = commandFingerprint(this.options.cwd, argv), key = hash(inputHash + "\n" + configHash + "\n" + ext);
|
|
98
|
+
const cacheDir = join(this.options.stateDir, "text-projections"), cache = join(cacheDir, key + ".json");
|
|
99
|
+
try {
|
|
100
|
+
const cached = boundedCache(cache);
|
|
101
|
+
if (Buffer.byteLength(cached) <= MAX_TEXT * 2) {
|
|
102
|
+
const value = JSON.parse(cached) as ProjectedText;
|
|
103
|
+
if (value.inputHash === inputHash && value.rawHash === rawHash && value.rawBlob === rawBlob && value.configHash === configHash && typeof value.text === "string" && Buffer.byteLength(value.text) <= MAX_TEXT) return value;
|
|
104
|
+
}
|
|
105
|
+
} catch { /* missing/invalid cache is not evidence */ }
|
|
106
|
+
if (!sandboxAvailable()) throw new Error("sandbox unavailable");
|
|
107
|
+
scratch = realPath(mkdtempSync(join(tmpdir(), "ahub-projection-input-"))); const snapshot = join(scratch, "input" + ext);
|
|
108
|
+
writeFileSync(snapshot, bytes, { mode: 0o400 });
|
|
109
|
+
const result = spawnSync(process.execPath, [join(import.meta.dir, "projection-exec.ts")], {
|
|
110
|
+
cwd: this.options.cwd, timeout: deadline === undefined ? 8000 : Math.ceil(Math.max(1, Math.min(8000, deadline - performance.now()))), maxBuffer: MAX_TEXT * 2, encoding: "utf8",
|
|
111
|
+
input: JSON.stringify({ cwd: this.options.cwd, sandbox: this.sandbox + "\n(allow file-read* (literal " + sbplString(snapshot) + "))", argv: argv.map(arg => arg === "{input}" ? snapshot : arg), ...(deadline === undefined ? {} : { timeoutMs: Math.max(1, Math.min(5000, deadline - performance.now() - 100)) }) }),
|
|
112
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
|
|
113
|
+
});
|
|
114
|
+
if (result.error || result.status !== 0) throw new Error("conversion failed");
|
|
115
|
+
const converted = JSON.parse(result.stdout) as { ok?: boolean; text?: string };
|
|
116
|
+
if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw new Error("conversion failed");
|
|
117
|
+
verify?.();
|
|
118
|
+
const value = { inputHash, rawHash, rawBlob, configHash, text: converted.text };
|
|
119
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
120
|
+
const temporary = cache + "." + crypto.randomUUID() + ".tmp";
|
|
121
|
+
writeFileSync(temporary, JSON.stringify(value), { mode: 0o600 }); renameSync(temporary, cache);
|
|
122
|
+
return value;
|
|
123
|
+
} catch { this.warn(ext); return undefined; }
|
|
124
|
+
finally { if (scratch) rmSync(scratch, { recursive: true, force: true }); }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, join, relative, resolve, isAbsolute } from "node:path";
|
|
5
|
+
import { realPath } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #248: which add-ons a peer works with, from a closed list. Names and versions only, read from the machine's plugin
|
|
9
|
+
* inventory and launch settings (Claude Code's installed plugins and `enabledPlugins`, Codex's `config.toml`) and from
|
|
10
|
+
* the executables on PATH; never a plugin's content, settings values, hooks or paths.
|
|
11
|
+
* ponytail: the inventory is the machine's, not the session's: a session started with its own `--settings` or plugin
|
|
12
|
+
* flags can differ. Reading the launch record of each `ahub claude` / `ahub codex` would make it exact.
|
|
13
|
+
*/
|
|
14
|
+
export const ADDONS = ["claude-mem", "ponytail", "ripwire", "open-code-review", "codex-plugin", "dot-admit"] as const;
|
|
15
|
+
export type AddonName = (typeof ADDONS)[number];
|
|
16
|
+
export interface Addon { name: AddonName; version: string | null }
|
|
17
|
+
|
|
18
|
+
/** Plugin names (before the `@marketplace`) that stand for an add-on. */
|
|
19
|
+
// Maps, not objects: a plugin named `constructor` or `toString` must find nothing, not an inherited property.
|
|
20
|
+
const PLUGINS = new Map<string, AddonName>([["claude-mem", "claude-mem"], ["ponytail", "ponytail"], ["open-code-review", "open-code-review"]]);
|
|
21
|
+
/** Claude Code only: the Codex plugin is a Claude plugin named `codex` from the `openai-codex` marketplace. */
|
|
22
|
+
const CLAUDE_ONLY = new Map<string, AddonName>([["codex@openai-codex", "codex-plugin"]]);
|
|
23
|
+
/**
|
|
24
|
+
* Executables any peer with a shell can run, and how each is told apart from another program of the same name: the
|
|
25
|
+
* first one on PATH is asked for its version (bounded, no stdin), so Graphviz's `dot` is never resource admission.
|
|
26
|
+
*/
|
|
27
|
+
const BINARIES: Record<string, { name: AddonName; version?: RegExp }> = { ripwire: { name: "ripwire" }, dot: { name: "dot-admit", version: /^dot version (\S+)/ } };
|
|
28
|
+
/** By resolved path and modification time: one version call per binary per process. */
|
|
29
|
+
const identities = new Map<string, Promise<string | null | false>>();
|
|
30
|
+
const PROBE_MS = 2000, PROBE_BYTES = 4096;
|
|
31
|
+
|
|
32
|
+
/** `env` supplies CLAUDE_CONFIG_DIR and CODEX_HOME, the runtimes' own config roots (the hub's environment by default). */
|
|
33
|
+
export interface ToolingSources { home?: string; cwd?: string; path?: string; env?: Record<string, string | undefined> }
|
|
34
|
+
|
|
35
|
+
const json = (file: string): Record<string, unknown> | undefined => {
|
|
36
|
+
try { const v = JSON.parse(readFileSync(file, "utf8")); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
|
|
37
|
+
};
|
|
38
|
+
/** A version string as data, never anything longer or stranger. */
|
|
39
|
+
const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
|
|
40
|
+
|
|
41
|
+
function claudePlugins(root: string, cwd: string | undefined): Addon[] {
|
|
42
|
+
const enabled = new Map<string, boolean>();
|
|
43
|
+
for (const file of [join(root, "settings.json"), ...(cwd ? [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")] : [])]) {
|
|
44
|
+
const map = json(file)?.enabledPlugins;
|
|
45
|
+
if (map && typeof map === "object") for (const [key, on] of Object.entries(map)) enabled.set(key, on === true);
|
|
46
|
+
}
|
|
47
|
+
const installed = (json(join(root, "plugins", "installed_plugins.json"))?.plugins ?? {}) as Record<string, { scope?: string; projectPath?: string; version?: unknown }[]>;
|
|
48
|
+
const out: Addon[] = [];
|
|
49
|
+
for (const [key, on] of enabled) {
|
|
50
|
+
if (!on) continue;
|
|
51
|
+
const name = CLAUDE_ONLY.get(key) ?? PLUGINS.get(key.split("@")[0]!);
|
|
52
|
+
const entries = Array.isArray(installed[key]) ? installed[key]! : [];
|
|
53
|
+
// An install for another project is not this one's.
|
|
54
|
+
const entry = entries.find((e) => e?.scope === "user") ?? entries.find((e) => e?.projectPath === cwd);
|
|
55
|
+
if (name && entry) out.push({ name, version: version(entry.version) });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function codexPlugins(root: string): Addon[] {
|
|
61
|
+
let text: string;
|
|
62
|
+
try { text = readFileSync(join(root, "config.toml"), "utf8"); } catch { return []; }
|
|
63
|
+
const found = new Map<string, string>(); // plugin name -> marketplace
|
|
64
|
+
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust.
|
|
65
|
+
for (const m of text.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!);
|
|
66
|
+
for (const m of text.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) found.set(m[1]!, m[2]!);
|
|
67
|
+
const out: Addon[] = [];
|
|
68
|
+
for (const [plugin, market] of found) {
|
|
69
|
+
const name = PLUGINS.get(plugin);
|
|
70
|
+
if (!name) continue;
|
|
71
|
+
let versions: string[] = [];
|
|
72
|
+
try { versions = readdirSync(join(root, "plugins", "cache", market, plugin)); } catch { /* no cache: version unknown */ }
|
|
73
|
+
out.push({ name, version: versions.length === 1 ? version(versions[0]) : null });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Never throws: a path that cannot be resolved (a project directory that went away) is compared as given. */
|
|
79
|
+
const real = (path: string): string => { try { return realPath(path); } catch { return resolve(path); } };
|
|
80
|
+
const within = (file: string, dir: string): boolean => { const r = relative(dir, file); return r === "" || (!r.startsWith("..") && !isAbsolute(r)); };
|
|
81
|
+
|
|
82
|
+
/** The first executable of that name on PATH, relative and empty entries resolved from the peer's working directory. */
|
|
83
|
+
function onPath(name: string, path: string, cwd: string): string | undefined {
|
|
84
|
+
for (const dir of path.split(delimiter)) {
|
|
85
|
+
const file = resolve(cwd, dir || ".", name);
|
|
86
|
+
// A shell runs the first executable regular file; a directory of that name is skipped like a missing one.
|
|
87
|
+
try { if (statSync(file).isFile()) { accessSync(file, constants.X_OK); return file; } } catch { /* not here */ }
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The version an executable reports when it is the expected program, `null` when that is unknown, `false` when it is
|
|
93
|
+
* another program or does not answer within PROBE_MS. Asynchronous with a hard deadline: a program that ignores SIGTERM
|
|
94
|
+
* is killed outright and never waited on, so it can never hold up the hub.
|
|
95
|
+
*/
|
|
96
|
+
function identify(file: string, pattern: RegExp, env: Record<string, string>): Promise<string | null | false> {
|
|
97
|
+
let key = file;
|
|
98
|
+
try { key = `${file}:${statSync(file).mtimeMs}`; } catch { /* unreadable: ask anyway */ }
|
|
99
|
+
let found = identities.get(key);
|
|
100
|
+
if (!found) {
|
|
101
|
+
found = new Promise<string | null | false>((resolve) => {
|
|
102
|
+
let out = "", settled = false, child: ChildProcess | undefined;
|
|
103
|
+
// Its own process group, killed as one when the answer is in or the deadline passes: a descendant that kept the
|
|
104
|
+
// pipe open would otherwise outlive the probe and hold up whoever waits on it.
|
|
105
|
+
const finish = (answer: string | null | false) => {
|
|
106
|
+
if (settled) return;
|
|
107
|
+
settled = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
try { if (child?.pid) process.kill(-child.pid, "SIGKILL"); } catch { /* the group is gone */ }
|
|
110
|
+
child?.stdout?.destroy();
|
|
111
|
+
resolve(answer);
|
|
112
|
+
};
|
|
113
|
+
const timer = setTimeout(() => finish(false), PROBE_MS);
|
|
114
|
+
try {
|
|
115
|
+
// Only PATH (its absolute entries outside the project, so an env-shebang interpreter still resolves), HOME and
|
|
116
|
+
// the C locale reach the probed program: no gateway keys, Access values or agent markers.
|
|
117
|
+
child = spawn(file, ["--version"], { detached: true, stdio: ["ignore", "pipe", "ignore"], env });
|
|
118
|
+
child.unref();
|
|
119
|
+
// A version line is short: a program that keeps writing is not the one asked for, and is not read further.
|
|
120
|
+
child.stdout?.on("data", (chunk: Buffer) => { out += chunk.toString("utf8"); if (out.length > PROBE_BYTES) finish(false); });
|
|
121
|
+
child.on("error", () => finish(false));
|
|
122
|
+
// The version line is written before the program exits; a moment for the pipe to deliver it.
|
|
123
|
+
child.on("exit", () => setTimeout(() => { const m = pattern.exec(out.trim()); finish(m ? version(m[1]) : false); }, 50));
|
|
124
|
+
} catch { finish(false); }
|
|
125
|
+
});
|
|
126
|
+
identities.set(key, found);
|
|
127
|
+
}
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The add-ons a peer works with, sorted by name. Unreadable sources contribute nothing; they never throw. */
|
|
132
|
+
export async function detectTooling(peer: string, src: ToolingSources = {}): Promise<Addon[]> {
|
|
133
|
+
const home = src.home ?? homedir();
|
|
134
|
+
const found = new Map<AddonName, Addon>();
|
|
135
|
+
const add = (a: Addon) => { if (!found.has(a.name) || (found.get(a.name)!.version === null && a.version !== null)) found.set(a.name, a); };
|
|
136
|
+
const env = src.env ?? process.env;
|
|
137
|
+
if (peer === "claude") claudePlugins(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), src.cwd).forEach(add);
|
|
138
|
+
if (peer === "codex") codexPlugins(env.CODEX_HOME?.trim() || join(home, ".codex")).forEach(add);
|
|
139
|
+
const path = src.path ?? process.env.PATH ?? "";
|
|
140
|
+
for (const [bin, spec] of Object.entries(BINARIES)) {
|
|
141
|
+
const file = onPath(bin, path, src.cwd ?? process.cwd());
|
|
142
|
+
if (!file) continue;
|
|
143
|
+
// A program inside the project is the repository's, not an installed tool: it is never run to identify it (opening a
|
|
144
|
+
// checkout must not execute its files), so it does not count where its identity has to be checked.
|
|
145
|
+
const inProject = src.cwd !== undefined && within(real(file), real(src.cwd));
|
|
146
|
+
const probeEnv = { PATH: path.split(delimiter).filter((d) => isAbsolute(d) && !(src.cwd !== undefined && within(real(d), real(src.cwd)))).join(delimiter) || "/usr/bin:/bin", HOME: home, LANG: "C", LC_ALL: "C" };
|
|
147
|
+
const v = spec.version ? (inProject ? false : await identify(file, spec.version, probeEnv)) : null;
|
|
148
|
+
if (v !== false) add({ name: spec.name, version: v });
|
|
149
|
+
}
|
|
150
|
+
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `on` when any of the peers has the add-on, `off` when every peer's fingerprint is known and none has it, else unknown. */
|
|
154
|
+
export function addonState(name: AddonName, fingerprints: (Addon[] | undefined)[]): "on" | "off" | "unknown" {
|
|
155
|
+
if (fingerprints.some((f) => f?.some((a) => a.name === name))) return "on";
|
|
156
|
+
return fingerprints.length && fingerprints.every((f) => f !== undefined) ? "off" : "unknown";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const isAddon = (name: string): name is AddonName => (ADDONS as readonly string[]).includes(name);
|
package/src/hub/ui.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
|
|
2
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
|
+
import { boardColumns } from "../ui/task-board.ts";
|
|
4
|
+
import { PEER_PALETTE, peerPaletteCss } from "../ui/peer-palette.ts";
|
|
5
|
+
import { PEER_START_RETRY_MS } from "./start-mode.ts";
|
|
1
6
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
7
|
+
import { officeModel } from "../ui/office-model.ts";
|
|
8
|
+
import { needs } from "../ui/needs.ts";
|
|
9
|
+
import { CHARACTERS } from "../pet/characters.ts";
|
|
10
|
+
import { petPixels } from "../pet/pixels.ts";
|
|
2
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
12
|
import { readFileSync } from "node:fs";
|
|
4
13
|
|
|
14
|
+
/** What a request's session may do (#269): `settings` while a session opened by `ahub ui --settings` is inside its window. */
|
|
15
|
+
export interface DashboardSession { settings: boolean; settingsUntil?: number }
|
|
5
16
|
export interface DashboardOptions {
|
|
6
|
-
snapshot: (after: number, input?: Record<string, unknown
|
|
7
|
-
action: (input: Record<string, unknown
|
|
17
|
+
snapshot: (after: number, input?: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
|
|
18
|
+
action: (input: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
|
|
8
19
|
projects?: () => unknown | Promise<unknown>;
|
|
9
20
|
selectedProjectId?: string;
|
|
10
21
|
/** Injectable clock for expiry tests; production uses wall time. */
|
|
@@ -13,17 +24,24 @@ export interface DashboardOptions {
|
|
|
13
24
|
|
|
14
25
|
const TICKET_MS = 60_000;
|
|
15
26
|
const SESSION_MS = 60 * 60_000;
|
|
27
|
+
/** How long a session opened by `ahub ui --settings` may change settings that raise authority; it stays an ordinary session after. */
|
|
28
|
+
export const SETTINGS_MS = 15 * 60_000;
|
|
16
29
|
const LIMIT = 64;
|
|
17
30
|
const secret = () => randomBytes(32).toString("hex");
|
|
18
31
|
|
|
19
32
|
/** Separate browser surface. Only the authenticated control console may construct it and issue tickets. */
|
|
20
33
|
export function startDashboard(options: DashboardOptions) {
|
|
21
34
|
const now = options.now ?? Date.now;
|
|
22
|
-
const tickets = new Map<string, number>();
|
|
23
|
-
const sessions = new Map<string, number>();
|
|
35
|
+
const tickets = new Map<string, { expires: number; settings: boolean }>();
|
|
36
|
+
const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
|
|
24
37
|
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
25
38
|
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
26
|
-
.replace("/*
|
|
39
|
+
.replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
|
|
40
|
+
.replace("/* PEER_PALETTE_CSS */", () => peerPaletteCss())
|
|
41
|
+
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
|
|
42
|
+
.replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`)
|
|
43
|
+
.replace("/* OFFICE_MODEL */", () => `const officeModel = ${officeModel.toString()};`)
|
|
44
|
+
.replace("/* PET_MODEL */", () => `const needs = ${needs.toString()}; const petCharacters = ${JSON.stringify(Object.fromEntries(Object.entries(CHARACTERS).map(([id, character]) => [id, { name: character.name, accent: character.accent, frames: { blocks: Object.fromEntries(Object.entries(character.frames.blocks).map(([pose, sizes]) => [pose, { full: sizes.full }])) } }])))}; const petPixels = ${petPixels.toString()};`);
|
|
27
45
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
28
46
|
.map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
|
|
29
47
|
const headers = {
|
|
@@ -36,14 +54,18 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
36
54
|
const json = (body: unknown, status = 200, extra: Record<string, string> = {}) =>
|
|
37
55
|
new Response(JSON.stringify(body), { status, headers: { ...headers, "Content-Type": "application/json", ...extra } });
|
|
38
56
|
const reject = (status: number, error: string) => json({ ok: false, error }, status);
|
|
39
|
-
function prune(map: Map<string, number>) {
|
|
40
|
-
for (const [key,
|
|
57
|
+
function prune(map: Map<string, { expires: number }>) {
|
|
58
|
+
for (const [key, entry] of map) if (entry.expires <= now()) map.delete(key);
|
|
41
59
|
}
|
|
60
|
+
const sessionOf = (cookie: string | undefined): DashboardSession => {
|
|
61
|
+
const until = cookie ? sessions.get(cookie)?.settingsUntil : undefined;
|
|
62
|
+
return until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false };
|
|
63
|
+
};
|
|
42
64
|
const server = Bun.serve({
|
|
43
65
|
hostname: "127.0.0.1",
|
|
44
66
|
port: 0,
|
|
45
67
|
maxRequestBodySize: 32 * 1024,
|
|
46
|
-
async fetch(req): Promise<Response> {
|
|
68
|
+
async fetch(req, server): Promise<Response> {
|
|
47
69
|
if (req.headers.get("host") !== host) return reject(403, "forbidden host");
|
|
48
70
|
const requestOrigin = req.headers.get("origin");
|
|
49
71
|
if (requestOrigin !== null && requestOrigin !== origin) return reject(403, "forbidden origin");
|
|
@@ -75,10 +97,11 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
75
97
|
if (path !== "/session" && (!cookie || !sessions.has(cookie))) return reject(401, "session expired; run ahub ui");
|
|
76
98
|
if (path === "/session") {
|
|
77
99
|
const ticket = typeof input.ticket === "string" ? input.ticket : "";
|
|
78
|
-
|
|
100
|
+
const issued = tickets.get(ticket);
|
|
101
|
+
if (!issued || !tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
|
|
79
102
|
if (sessions.size >= LIMIT) return reject(429, "too many sessions; try later");
|
|
80
103
|
const session = secret();
|
|
81
|
-
sessions.set(session, now() + SESSION_MS);
|
|
104
|
+
sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}) });
|
|
82
105
|
return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
|
|
83
106
|
}
|
|
84
107
|
if (path === "/projects") {
|
|
@@ -97,7 +120,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
97
120
|
const after = input.after ?? 0;
|
|
98
121
|
if (typeof after !== "number" || !Number.isSafeInteger(after) || after < 0) return reject(400, "invalid cursor");
|
|
99
122
|
try {
|
|
100
|
-
const result = await options.snapshot(after, input);
|
|
123
|
+
const result = await options.snapshot(after, input, sessionOf(cookie));
|
|
101
124
|
if (!result || typeof result !== "object" || Array.isArray(result)) return json(result);
|
|
102
125
|
const response = { ...(result as Record<string, unknown>) };
|
|
103
126
|
if (typeof response.projectId === "string" && typeof input.projectId === "string" && response.projectId !== input.projectId) return reject(409, "snapshot project changed; refresh the dashboard");
|
|
@@ -110,7 +133,9 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
110
133
|
}
|
|
111
134
|
}
|
|
112
135
|
try {
|
|
113
|
-
|
|
136
|
+
if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
|
|
137
|
+
else if (input.action === "backlog_split") server.timeout(req, Math.ceil((BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000) / 1000) + 5);
|
|
138
|
+
return json(await options.action(input, sessionOf(cookie)));
|
|
114
139
|
} catch {
|
|
115
140
|
// Exceptions may quote private task text or backend details. Never echo them to the page.
|
|
116
141
|
return reject(400, "action failed; check its inputs and the terminal");
|
|
@@ -125,11 +150,12 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
125
150
|
const cookieName = `ahub_ui_${server.port}`;
|
|
126
151
|
return {
|
|
127
152
|
origin,
|
|
128
|
-
|
|
153
|
+
/** `settings`: the ticket of `ahub ui --settings`, whose session may raise authority for SETTINGS_MS (#269). */
|
|
154
|
+
issue(settings = false): string {
|
|
129
155
|
prune(tickets);
|
|
130
156
|
if (tickets.size >= LIMIT) tickets.delete(tickets.keys().next().value!);
|
|
131
157
|
const ticket = secret();
|
|
132
|
-
tickets.set(ticket, now() + TICKET_MS);
|
|
158
|
+
tickets.set(ticket, { expires: now() + TICKET_MS, settings });
|
|
133
159
|
return `${origin}/#${ticket}`;
|
|
134
160
|
},
|
|
135
161
|
stop() {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Task } from "./board.ts";
|
|
3
|
+
import type { SdlcConfig } from "./sdlc.ts";
|
|
4
|
+
|
|
5
|
+
export interface PhaseReceipt {
|
|
6
|
+
id: string;
|
|
7
|
+
head?: string;
|
|
8
|
+
tasks: { id: number; after: number[] }[];
|
|
9
|
+
criteria: { id: string; required: boolean; result: "verified" | "failed" | "unverified"; refs: string[] }[];
|
|
10
|
+
}
|
|
11
|
+
const taskId = (n: unknown): n is number => Number.isSafeInteger(n) && typeof n === "number" && n > 0;
|
|
12
|
+
const name = (s: unknown): s is string => typeof s === "string" && /^[a-z][a-z0-9-]{0,47}$/.test(s);
|
|
13
|
+
const hash = (s: unknown): s is string => typeof s === "string" && /^[0-9a-f]{40}$/.test(s);
|
|
14
|
+
const fields = (v: unknown, keys: string[], label: string): Record<string, unknown> => {
|
|
15
|
+
if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).some(k => !keys.includes(k))) throw new Error(`${label}: invalid fields`);
|
|
16
|
+
return v as Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
export function phaseReceipt(value: unknown): PhaseReceipt {
|
|
19
|
+
const serialized = JSON.stringify(value);
|
|
20
|
+
if (typeof serialized !== "string") throw new Error("phase: invalid fields");
|
|
21
|
+
if (Buffer.byteLength(serialized) > 32768) throw new Error("phase: receipt exceeds 32 KiB");
|
|
22
|
+
const p = fields(value, ["id", "head", "tasks", "criteria"], "phase");
|
|
23
|
+
if (!name(p.id) || (p.head !== undefined && !hash(p.head))) throw new Error("phase: expected bounded id and full head SHA");
|
|
24
|
+
if (!Array.isArray(p.tasks) || p.tasks.length > 64 || !Array.isArray(p.criteria) || !p.criteria.length || p.criteria.length > 32) throw new Error("phase: at most 64 tasks and 1..32 criteria");
|
|
25
|
+
const tasks = p.tasks.map(raw => {
|
|
26
|
+
const t = fields(raw, ["id", "after"], "phase task");
|
|
27
|
+
if (!taskId(t.id) || !Array.isArray(t.after) || t.after.length > 16 || !t.after.every(taskId) || new Set(t.after).size !== t.after.length || t.after.includes(t.id)) throw new Error("phase task: invalid id or predecessors");
|
|
28
|
+
return { id: t.id, after: [...t.after] as number[] };
|
|
29
|
+
});
|
|
30
|
+
const criteria = p.criteria.map(raw => {
|
|
31
|
+
const c = fields(raw, ["id", "required", "result", "refs"], "phase criterion");
|
|
32
|
+
if (!name(c.id) || typeof c.required !== "boolean" || !["verified", "failed", "unverified"].includes(String(c.result)) || !Array.isArray(c.refs) || c.refs.length > 8 || new Set(c.refs).size !== c.refs.length || c.refs.some(ref => typeof ref !== "string" || !/^(sha256:[0-9a-f]{64}|(artifact|check|source):[A-Za-z0-9._/-]{1,160}@[0-9a-f]{64})$/.test(ref)) || (c.result === "verified" && !c.refs.length)) throw new Error("phase criterion: invalid outcome or hashed evidence refs");
|
|
33
|
+
return { id: c.id, required: c.required, result: c.result as PhaseReceipt["criteria"][number]["result"], refs: [...c.refs] as string[] };
|
|
34
|
+
});
|
|
35
|
+
if (new Set(tasks.map(t => t.id)).size !== tasks.length || new Set(criteria.map(c => c.id)).size !== criteria.length) throw new Error("phase: duplicate task or criterion");
|
|
36
|
+
return { id: p.id, ...(p.head ? { head: p.head as string } : {}), tasks, criteria };
|
|
37
|
+
}
|
|
38
|
+
export type TaskLookup = (id: number) => Task | null | undefined;
|
|
39
|
+
/** An import names existing Tasks.after edges, it never edits dependencies or ownership. */
|
|
40
|
+
export function validatePhaseLinks(parent: Task, phase: PhaseReceipt, get: TaskLookup, isPrivate: (t: Task) => boolean): void {
|
|
41
|
+
const seen = new Set<number>(), active = new Set<number>();
|
|
42
|
+
const visit = (id: number): void => {
|
|
43
|
+
if (id === parent.id) throw new Error("phase: parent cannot be its own prerequisite or child");
|
|
44
|
+
if (active.has(id)) throw new Error("phase: dependency cycle");
|
|
45
|
+
if (seen.has(id)) return;
|
|
46
|
+
if (seen.size >= 256) throw new Error("phase: dependency closure exceeds 256 tasks");
|
|
47
|
+
const task = get(id); if (!task) throw new Error("phase: referenced task missing");
|
|
48
|
+
if (!isPrivate(parent) && isPrivate(task)) throw new Error("phase: ordinary parent cannot reference private work");
|
|
49
|
+
seen.add(id); active.add(id);
|
|
50
|
+
for (const predecessor of task.deps ?? []) visit(predecessor);
|
|
51
|
+
active.delete(id);
|
|
52
|
+
};
|
|
53
|
+
for (const link of phase.tasks) {
|
|
54
|
+
visit(link.id);
|
|
55
|
+
const actual = get(link.id)!.deps ?? [];
|
|
56
|
+
if (actual.length !== link.after.length || actual.some(id => !link.after.includes(id))) throw new Error("phase: predecessor ids must match current Tasks.after edges");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export interface PhaseView { workflow: "gsd-lite"; id?: string; ready: boolean; blockers: string[]; tasks?: { id: number; after: number[]; state: string }[]; criteria?: PhaseReceipt["criteria"]; }
|
|
60
|
+
export function phaseView(config: SdlcConfig, parent: Task, get: TaskLookup, isPrivate: (t: Task) => boolean): PhaseView | undefined {
|
|
61
|
+
if (config.workflow !== "gsd-lite") return;
|
|
62
|
+
const blockers: string[] = [], phase = parent.sdlc?.phase;
|
|
63
|
+
if (isPrivate(parent)) return { workflow: "gsd-lite", ready: false, blockers: ["private phase evidence unknown"] };
|
|
64
|
+
if (!phase) return { workflow: "gsd-lite", ready: false, blockers: ["phase checklist missing"] };
|
|
65
|
+
try { validatePhaseLinks(parent, phase, get, isPrivate); } catch { return { workflow: "gsd-lite", id: phase.id, ready: false, blockers: ["phase dependency evidence changed or unavailable"] }; }
|
|
66
|
+
for (const criterion of phase.criteria) if (criterion.required && criterion.result !== "verified") blockers.push(`phase criterion ${criterion.id}: ${criterion.result}`);
|
|
67
|
+
const tasks = phase.tasks.map(link => ({ id: link.id, after: [...(get(link.id)?.deps ?? [])], state: get(link.id)?.state ?? "unknown" }));
|
|
68
|
+
if (tasks.some(task => task.state !== "approved")) blockers.push("phase child tasks not approved");
|
|
69
|
+
return { workflow: "gsd-lite", id: phase.id, ready: blockers.length === 0, blockers, tasks, criteria: phase.criteria.map(c => ({ ...c, refs: [...c.refs] })) };
|
|
70
|
+
}
|
|
71
|
+
export function workflowHash(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
package/src/local/deny.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { basename, sep } from "node:path";
|
|
1
|
+
import { basename, dirname, sep } from "node:path";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
|
|
@@ -50,13 +50,23 @@ const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) =>
|
|
|
50
50
|
* rename, symlink or gitfile, and `config` and `hooks` directly under it are folded too; the cost is that
|
|
51
51
|
* `git init`, `git clone` and `git worktree add` no longer run inside the sandbox. `commondir` is refused at any
|
|
52
52
|
* depth below a `.git` and below each external git dir: that is where git keeps it for linked worktrees and
|
|
53
|
-
* submodules.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* submodules. A submodule's git dir under `.git/modules/` gets the same `config`/`hooks` refusal at whatever depth
|
|
54
|
+
* submodules nest (a nested one lives under `.git/modules/<a>/modules/<b>`, #281), anchored at the discovered
|
|
55
|
+
* modules root so a directory named `refs`, `config` or `hooks` ABOVE the project neither disables the rule nor
|
|
56
|
+
* trips it; a `/refs/` or `/logs/` segment below the root keeps a branch or tag named `config` or `hooks` writable
|
|
57
|
+
* (the round-3 regression of #277), at the price of leaving a submodule whose own path holds a `refs` or `logs`
|
|
58
|
+
* segment unprotected. The basenames are folded; a folded INTERMEDIATE component on HFS+ (a spelling of `modules`)
|
|
59
|
+
* is a known residual, the same one the external git dir rules carry. The directory entries above those files are
|
|
60
|
+
* protected as the `.git` name is: the discovered git dirs, the modules root and every component in between are
|
|
61
|
+
* refused as targets (rename, replace, symlink, recreate). An external git dir's `config`/`hooks` rules cover only
|
|
62
|
+
* its own children (plus the hooks directory's contents): at any depth they would deny refs named `config` or
|
|
63
|
+
* `hooks` in a worktree or submodule project (`git branch fix/config` fails on `.git/logs/refs/heads/fix/config`).
|
|
64
|
+
* Every pattern keeps its `^` anchor (an unanchored starred alternation does not match in this engine) and no
|
|
65
|
+
* folded literal contains the root: an SBPL string literal dies at 1024 bytes ("Error reading string"), which a
|
|
66
|
+
* long project path would trip. ponytail: the modules rules do contain the (plain) modules root; a project path
|
|
67
|
+
* over roughly 600 bytes with submodules would exceed the limit — fold the root's segments if one ever shows up.
|
|
58
68
|
*/
|
|
59
|
-
export function hubWriteRegexes(root: string, gitDirs: string[] = []): string[] {
|
|
69
|
+
export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit?: { root: string; entries: string[] }): string[] {
|
|
60
70
|
const git = hfsFoldedName(".git");
|
|
61
71
|
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
62
72
|
const external = gitDirs.flatMap((d) => [
|
|
@@ -64,12 +74,24 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = []): string[]
|
|
|
64
74
|
`(subpath ${sbplString(`${d}/hooks`)})`,
|
|
65
75
|
`(require-all (subpath ${sbplString(d)}) ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
66
76
|
]);
|
|
77
|
+
const modules = moduleGit
|
|
78
|
+
? [
|
|
79
|
+
// Every name matches whole components only: `(.*/)?` forces a boundary, so a submodule named `catalogs`
|
|
80
|
+
// or `prefs` keeps the refusal and one named `webhooks` keeps its ordinary writes (#281 round 2).
|
|
81
|
+
`(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("config")}$`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
|
|
82
|
+
`(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("hooks")}(/|$)`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
|
|
83
|
+
// A submodule git dir as a directory entry: no rename, replace, symlink or recreate. The basename is
|
|
84
|
+
// folded (HFS+ opens the folded spelling as the real entry); the plain prefix is the path as discovered.
|
|
85
|
+
...moduleGit.entries.map((d) => re(`^${sbplEscape(dirname(d))}/${hfsFoldedName(basename(d))}$`)),
|
|
86
|
+
]
|
|
87
|
+
: [];
|
|
67
88
|
return [
|
|
68
89
|
`(require-all (subpath ${sbplString(root)}) ${re(`^.*/${hfsFoldedName(".agenthub")}(/|$)`)})`,
|
|
69
90
|
re(`^.*/${git}$`),
|
|
70
91
|
re(`^.*/${git}/${hfsFoldedName("config")}$`),
|
|
71
92
|
re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
|
|
72
93
|
`(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
94
|
+
...modules,
|
|
73
95
|
...external,
|
|
74
96
|
];
|
|
75
97
|
}
|