@staix/agent-hub 0.12.23 → 0.12.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +8 -3
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +10 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +8 -0
  15. package/docs/agent-notes/tests.md +5 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +666 -19
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +2 -2
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +288 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +633 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
  43. package/docs/verification/2026-10-14-0.12.25.md +27 -0
  44. package/docs/verified.json +22 -13
  45. package/package.json +2 -1
  46. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  47. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  48. package/plugins/agent-hub/server.js +1175 -339
  49. package/scripts/check-docs.mjs +129 -0
  50. package/src/adapters/acp.ts +112 -13
  51. package/src/adapters/claude-channel.ts +48 -11
  52. package/src/adapters/codex-appserver.ts +11 -0
  53. package/src/adapters/jcode.ts +110 -0
  54. package/src/adapters/local-worker.ts +10 -7
  55. package/src/adapters/pi.ts +171 -23
  56. package/src/cli/attention.ts +60 -0
  57. package/src/cli/bench.ts +24 -5
  58. package/src/cli/board-read.ts +12 -0
  59. package/src/cli/console-state.ts +103 -17
  60. package/src/cli/console.ts +54 -8
  61. package/src/cli/facts-hook.ts +12 -4
  62. package/src/cli/help.ts +35 -9
  63. package/src/cli/identity.ts +18 -7
  64. package/src/cli/init.ts +48 -10
  65. package/src/cli/jcode-mcp.ts +58 -0
  66. package/src/cli/kimi-mcp.ts +53 -0
  67. package/src/cli/launch.ts +16 -1
  68. package/src/cli/main.ts +392 -57
  69. package/src/cli/memory.ts +193 -0
  70. package/src/cli/output.ts +45 -4
  71. package/src/cli/pet-config.ts +1 -0
  72. package/src/cli/pet.ts +83 -0
  73. package/src/cli/preview.ts +38 -5
  74. package/src/cli/status-lines.ts +1 -1
  75. package/src/cli/tail-render.ts +1 -0
  76. package/src/cli/upgrade-runtime.ts +32 -3
  77. package/src/cli/watch.ts +31 -0
  78. package/src/hub/activity.ts +53 -0
  79. package/src/hub/ask.ts +10 -5
  80. package/src/hub/bench.ts +10 -3
  81. package/src/hub/board.ts +222 -16
  82. package/src/hub/bus.ts +48 -9
  83. package/src/hub/citations.ts +7 -0
  84. package/src/hub/conductor.ts +12 -7
  85. package/src/hub/config-trust.ts +4 -2
  86. package/src/hub/crash.ts +9 -0
  87. package/src/hub/daemon.ts +914 -120
  88. package/src/hub/delivery-journal.ts +5 -0
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +12 -2
  91. package/src/hub/facts.ts +26 -7
  92. package/src/hub/git.ts +8 -2
  93. package/src/hub/github-import.ts +28 -0
  94. package/src/hub/hub-tools.ts +49 -10
  95. package/src/hub/inference.ts +1 -1
  96. package/src/hub/lifecycle-schema.ts +17 -0
  97. package/src/hub/listen.ts +15 -0
  98. package/src/hub/manager.ts +12 -5
  99. package/src/hub/path-policy.ts +115 -0
  100. package/src/hub/peer-catalog.ts +28 -0
  101. package/src/hub/permission-mode.ts +19 -5
  102. package/src/hub/planning.ts +70 -0
  103. package/src/hub/profiles.ts +74 -0
  104. package/src/hub/projected-diff.ts +73 -0
  105. package/src/hub/projection-exec.ts +24 -0
  106. package/src/hub/research.ts +144 -21
  107. package/src/hub/roles.ts +123 -0
  108. package/src/hub/roster.ts +23 -0
  109. package/src/hub/routing.ts +18 -5
  110. package/src/hub/sdlc-queue.ts +112 -0
  111. package/src/hub/sdlc-release.ts +125 -0
  112. package/src/hub/sdlc.ts +250 -0
  113. package/src/hub/settings.ts +34 -15
  114. package/src/hub/shipped-roles.ts +25 -0
  115. package/src/hub/snapshots.ts +89 -15
  116. package/src/hub/start-mode.ts +7 -5
  117. package/src/hub/supervision.ts +1 -0
  118. package/src/hub/task-sweep.ts +1 -1
  119. package/src/hub/tasks.ts +905 -89
  120. package/src/hub/text-projection.ts +151 -0
  121. package/src/hub/tooling.ts +170 -0
  122. package/src/hub/ui.ts +16 -2
  123. package/src/hub/workflow.ts +71 -0
  124. package/src/local/sandbox.ts +6 -4
  125. package/src/local/tools.ts +11 -6
  126. package/src/memory/brief.ts +28 -14
  127. package/src/memory/import.ts +55 -0
  128. package/src/memory/recall.ts +84 -0
  129. package/src/memory/store.ts +757 -0
  130. package/src/memory/sync.ts +145 -0
  131. package/src/pet/NOTICE.md +10 -0
  132. package/src/pet/characters.ts +1438 -0
  133. package/src/pet/config.ts +31 -0
  134. package/src/pet/frame.ts +17 -0
  135. package/src/pet/pixels.ts +28 -0
  136. package/src/pi/extension.ts +40 -5
  137. package/src/role-text.d.ts +1 -0
  138. package/src/ui/index.html +843 -46
  139. package/src/ui/needs.ts +58 -0
  140. package/src/ui/office-model.ts +440 -0
  141. package/src/ui/peer-palette.ts +16 -0
  142. package/src/ui/task-board.ts +49 -0
  143. package/src/ui/task-progress.ts +5 -1
  144. package/templates/AGENTS.block.md +3 -2
  145. package/templates/config.json +5 -1
  146. package/templates/profiles/dev.json +22 -0
  147. package/templates/profiles/general.json +26 -0
  148. package/templates/profiles/research.json +57 -0
  149. package/templates/profiles/writing.json +28 -0
  150. package/templates/roles/NOTICE.md +26 -0
  151. package/templates/roles/dev/backend-engineer.md +26 -0
  152. package/templates/roles/dev/code-reviewer.md +26 -0
  153. package/templates/roles/dev/frontend-engineer.md +26 -0
  154. package/templates/roles/dev/security-reviewer.md +26 -0
  155. package/templates/roles/dev/software-architect.md +26 -0
  156. package/templates/roles/dev/test-engineer.md +26 -0
  157. package/templates/roles/research/analyst.md +26 -0
  158. package/templates/roles/research/fact-checker.md +26 -0
  159. package/templates/roles/research/research-lead.md +26 -0
  160. package/templates/roles/research/researcher.md +26 -0
  161. package/templates/roles/research/statistician.md +26 -0
@@ -0,0 +1,151 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, 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
+ const EMPTY_ROOT = "/var/empty";
12
+ export const MAX_PROJECTION_INPUT = 16 * 1024 * 1024;
13
+ const MAX_INPUT = MAX_PROJECTION_INPUT, MAX_TEXT = 256 * 1024;
14
+ /** Only machine-local config chooses programs; no implicit shell or command-string parsing. */
15
+ export function projectionConfig(raw: unknown): ProjectionConfig {
16
+ if (raw === undefined) return {};
17
+ 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");
18
+ const out: ProjectionConfig = Object.create(null);
19
+ for (const [ext, argv] of Object.entries(raw)) {
20
+ 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");
21
+ out[ext] = [...argv];
22
+ }
23
+ return out;
24
+ }
25
+ export interface ProjectedText { inputHash: string; rawHash: string; rawBlob: string; configHash: string; text: string }
26
+ const hash = (data: string | Buffer) => createHash("sha256").update(data).digest("hex");
27
+ /** The program and script files an argv runs, resolved as the converter will see them. */
28
+ function programFiles(cwd: string, argv: string[]): { index: number; file: string; size: number; mtimeMs: number; ctimeMs: number }[] {
29
+ const out: { index: number; file: string; size: number; mtimeMs: number; ctimeMs: number }[] = [];
30
+ for (const [index, arg] of argv.entries()) {
31
+ if (index !== 0 && !/\.(?:sh|py|js|mjs|cjs|ts|rb|pl)$/.test(arg)) continue;
32
+ 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)];
33
+ for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue; out.push({ index, file, size: st.size, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs }); break; } catch { /* another PATH entry */ }
34
+ }
35
+ return out;
36
+ }
37
+ function commandFingerprint(cwd: string, argv: string[]): string {
38
+ return hash([JSON.stringify(argv), ...programFiles(cwd, argv).flatMap(p => [p.file, String(p.size), String(p.mtimeMs), String(p.ctimeMs)])].join("\\n"));
39
+ }
40
+ function boundedCache(file: string): string {
41
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
42
+ try {
43
+ const st = fstatSync(fd);
44
+ if (!st.isFile() || st.nlink !== 1 || st.size > MAX_TEXT * 2) throw new Error("invalid cache file");
45
+ const bytes = Buffer.alloc(st.size); let got = 0;
46
+ while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
47
+ if (got !== st.size) throw new Error("cache changed");
48
+ return bytes.toString("utf8");
49
+ } finally { closeSync(fd); }
50
+ }
51
+ /** Projections are presentation evidence, never proof of who wrote the original bytes. */
52
+ export class TextProjections {
53
+ private readonly sandbox: string;
54
+ private readonly warned = new Set<string>();
55
+ /** Inputs whose full-budget conversion failed: the same bytes under the same command are not converted again. */
56
+ private readonly failed = new Set<string>();
57
+ constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
58
+ // Rooted at an empty system directory, not the project: the converter reads its snapshot and program files only.
59
+ // Only where conversion can run: /var/empty is a macOS directory, and a host without the sandbox never converts.
60
+ this.sandbox = Object.keys(options.config).length && sandboxAvailable() ? profile(EMPTY_ROOT, false, options.readAllow, options.deny, ["."]) : "";
61
+ }
62
+ private warn(ext: string): void {
63
+ if (this.warned.has(ext)) return;
64
+ this.warned.add(ext); this.options.log?.("text projection " + ext + " unavailable; retained original facts behavior");
65
+ }
66
+ get enabled(): boolean { return Object.keys(this.options.config).length > 0; }
67
+ handles(file: string): boolean { return !!this.options.config[extname(file).toLowerCase()]; }
68
+ project(file: string): ProjectedText | undefined {
69
+ const ext = extname(file).toLowerCase(), argv = this.options.config[ext];
70
+ if (!argv) return undefined;
71
+ let fd: number | undefined;
72
+ try {
73
+ const root = realPath(this.options.cwd), input = realPath(file);
74
+ if (!input.startsWith(root + "/")) throw new Error("input outside project");
75
+ fd = openSync(input, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
76
+ const before = fstatSync(fd);
77
+ if (!before.isFile() || before.size > MAX_INPUT) throw new Error("input cap");
78
+ const bytes = Buffer.alloc(before.size); let got = 0;
79
+ while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
80
+ const after = fstatSync(fd);
81
+ if (got !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs) throw new Error("input changed");
82
+ return this.convert(ext, bytes, () => {
83
+ const final = fstatSync(fd!);
84
+ if (final.size !== before.size || final.mtimeMs !== before.mtimeMs) throw new Error("input changed during conversion");
85
+ });
86
+ } catch { this.warn(ext); return undefined; }
87
+ finally { if (fd !== undefined) closeSync(fd); }
88
+ }
89
+ /** Historic raw bytes; optional deadline uses performance.now(), shared across the preview. No current file read. */
90
+ projectBytes(file: string, bytes: Buffer, deadline?: number): ProjectedText | undefined {
91
+ const ext = extname(file).toLowerCase();
92
+ if (!this.options.config[ext]) return undefined;
93
+ try {
94
+ const root = realPath(this.options.cwd), input = resolve(root, file);
95
+ if (!input.startsWith(root + "/") || !Buffer.isBuffer(bytes) || bytes.length > MAX_INPUT) throw new Error("snapshot input cap or path");
96
+ return this.convert(ext, Buffer.from(bytes), undefined, deadline);
97
+ } catch { this.warn(ext); return undefined; }
98
+ }
99
+ private convert(ext: string, bytes: Buffer, verify?: () => void, deadline?: number): ProjectedText | undefined {
100
+ const argv = this.options.config[ext];
101
+ if (!argv) return undefined;
102
+ let scratch: string | undefined;
103
+ try {
104
+ if (deadline !== undefined && (!Number.isFinite(deadline) || deadline <= performance.now())) throw new Error("projection deadline");
105
+ const rawHash = createHash("sha1").update(bytes).digest("hex"), rawBlob = createHash("sha1").update("blob " + bytes.length + "\0").update(bytes).digest("hex");
106
+ const inputHash = hash(bytes), configHash = commandFingerprint(this.options.cwd, argv), key = hash(inputHash + "\n" + configHash + "\n" + ext);
107
+ const cacheDir = join(this.options.stateDir, "text-projections"), cache = join(cacheDir, key + ".json");
108
+ try {
109
+ const cached = boundedCache(cache);
110
+ if (Buffer.byteLength(cached) <= MAX_TEXT * 2) {
111
+ const value = JSON.parse(cached) as ProjectedText;
112
+ 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;
113
+ }
114
+ } catch { /* missing/invalid cache is not evidence */ }
115
+ if (this.failed.has(key)) throw new Error("conversion failed before");
116
+ if (!sandboxAvailable()) throw new Error("sandbox unavailable");
117
+ scratch = realPath(mkdtempSync(join(tmpdir(), "ahub-projection-input-"))); const snapshot = join(scratch, "input" + ext);
118
+ writeFileSync(snapshot, bytes, { mode: 0o400 });
119
+ // A read_allow entry may enclose the project. Seatbelt lets a later rule win only for the operation it names, so the
120
+ // deny and the literal grants after it both name file-read-data. The converter runs in its scratch directory (getcwd
121
+ // needs that directory's data), with its program and script arguments resolved to absolute paths.
122
+ const root = realPath(this.options.cwd), projectDeny = "\n(deny file-read-data (subpath " + sbplString(root) + "))";
123
+ const resolved = programFiles(this.options.cwd, argv);
124
+ // A module loader lists the directories above its script: grant those listings (not their files) up to the root.
125
+ const listings = new Set<string>();
126
+ for (const program of resolved) for (let dir = dirname(realPath(program.file)); dir.startsWith(root + "/") || dir === root; dir = dirname(dir)) { listings.add(dir); if (dir === root) break; }
127
+ const programs = resolved.map(p => "\n(allow file-read* file-read-data process-exec (literal " + sbplString(realPath(p.file)) + "))").join("")
128
+ + [...listings].map(dir => "\n(allow file-read-data (literal " + sbplString(dir) + "))").join("")
129
+ + "\n(allow file-read* file-read-data (literal " + sbplString(scratch) + "))";
130
+ const absolute = new Map(resolved.map(p => [p.index, p.file]));
131
+ const result = spawnSync(process.execPath, [join(import.meta.dir, "projection-exec.ts")], {
132
+ cwd: scratch, timeout: deadline === undefined ? 8000 : Math.ceil(Math.max(1, Math.min(8000, deadline - performance.now()))), maxBuffer: MAX_TEXT * 2, encoding: "utf8",
133
+ input: JSON.stringify({ cwd: scratch, sandbox: this.sandbox + projectDeny + "\n(allow file-read* file-read-data (literal " + sbplString(snapshot) + "))" + programs, argv: argv.map((arg, index) => arg === "{input}" ? snapshot : absolute.get(index) ?? arg), ...(deadline === undefined ? {} : { timeoutMs: Math.max(1, Math.min(5000, deadline - performance.now() - 100)) }) }),
134
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
135
+ });
136
+ // ponytail: in-memory for this process, cleared at 1024 keys; persist it if restarts make retries costly.
137
+ const failure = () => { if (deadline === undefined) { if (this.failed.size >= 1024) this.failed.clear(); this.failed.add(key); } return new Error("conversion failed"); };
138
+ if (result.error) throw new Error("conversion helper failed"); // a spawn or helper failure may be transient: not remembered
139
+ if (result.status !== 0) throw failure();
140
+ const converted = JSON.parse(result.stdout) as { ok?: boolean; text?: string };
141
+ if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw failure();
142
+ verify?.();
143
+ const value = { inputHash, rawHash, rawBlob, configHash, text: converted.text };
144
+ mkdirSync(cacheDir, { recursive: true });
145
+ const temporary = cache + "." + crypto.randomUUID() + ".tmp";
146
+ writeFileSync(temporary, JSON.stringify(value), { mode: 0o600 }); renameSync(temporary, cache);
147
+ return value;
148
+ } catch { this.warn(ext); return undefined; }
149
+ finally { if (scratch) rmSync(scratch, { recursive: true, force: true }); }
150
+ }
151
+ }
@@ -0,0 +1,170 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { accessSync, closeSync, constants, fstatSync, openSync, 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
+ /** A regular file of at most 1 MiB, opened without blocking: a FIFO or device under a project's `.claude/` reads as absent. */
36
+ const text = (file: string): string | undefined => {
37
+ let fd: number | undefined;
38
+ try {
39
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
40
+ const st = fstatSync(fd);
41
+ if (!st.isFile() || st.size > 1024 * 1024) return undefined;
42
+ return readFileSync(fd, "utf8");
43
+ } catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); }
44
+ };
45
+ const json = (file: string): Record<string, unknown> | undefined => {
46
+ try { const v = JSON.parse(text(file) ?? ""); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
47
+ };
48
+ /** A version string as data, never anything longer or stranger. */
49
+ const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
50
+
51
+ function claudePlugins(root: string, cwd: string | undefined): Addon[] {
52
+ const enabled = new Map<string, boolean>();
53
+ for (const file of [join(root, "settings.json"), ...(cwd ? [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")] : [])]) {
54
+ const map = json(file)?.enabledPlugins;
55
+ if (map && typeof map === "object") for (const [key, on] of Object.entries(map)) enabled.set(key, on === true);
56
+ }
57
+ const installed = (json(join(root, "plugins", "installed_plugins.json"))?.plugins ?? {}) as Record<string, { scope?: string; projectPath?: string; version?: unknown }[]>;
58
+ const out: Addon[] = [];
59
+ for (const [key, on] of enabled) {
60
+ if (!on) continue;
61
+ const name = CLAUDE_ONLY.get(key) ?? PLUGINS.get(key.split("@")[0]!);
62
+ const entries = Array.isArray(installed[key]) ? installed[key]! : [];
63
+ // An install for another project is not this one's.
64
+ const entry = entries.find((e) => e?.scope === "user") ?? entries.find((e) => e?.projectPath === cwd);
65
+ if (name && entry) out.push({ name, version: version(entry.version) });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function codexPlugins(root: string): Addon[] {
71
+ const config = text(join(root, "config.toml"));
72
+ if (config === undefined) return [];
73
+ const found = new Map<string, string>(), disabled = new Set<string>(); // plugin name -> marketplace
74
+ // `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
75
+ // unless their plugin entry disables them: a trust entry outlives the plugin's own switch.
76
+ for (const m of config.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!); else disabled.add(m[1]! + "@" + m[2]!);
77
+ for (const m of config.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) if (!disabled.has(m[1]! + "@" + m[2]!)) found.set(m[1]!, m[2]!);
78
+ const out: Addon[] = [];
79
+ for (const [plugin, market] of found) {
80
+ const name = PLUGINS.get(plugin);
81
+ if (!name) continue;
82
+ let versions: string[] = [];
83
+ try { versions = readdirSync(join(root, "plugins", "cache", market, plugin)); } catch { /* no cache: version unknown */ }
84
+ out.push({ name, version: versions.length === 1 ? version(versions[0]) : null });
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** Never throws: a path that cannot be resolved (a project directory that went away) is compared as given. */
90
+ const real = (path: string): string => { try { return realPath(path); } catch { return resolve(path); } };
91
+ const within = (file: string, dir: string): boolean => { const r = relative(dir, file); return r === "" || (!r.startsWith("..") && !isAbsolute(r)); };
92
+
93
+ /** The first executable of that name on PATH, relative and empty entries resolved from the peer's working directory. */
94
+ function onPath(name: string, path: string, cwd: string): string | undefined {
95
+ for (const dir of path.split(delimiter)) {
96
+ const file = resolve(cwd, dir || ".", name);
97
+ // A shell runs the first executable regular file; a directory of that name is skipped like a missing one.
98
+ try { if (statSync(file).isFile()) { accessSync(file, constants.X_OK); return file; } } catch { /* not here */ }
99
+ }
100
+ return undefined;
101
+ }
102
+ /**
103
+ * The version an executable reports when it is the expected program, `null` when that is unknown, `false` when it is
104
+ * another program or does not answer within PROBE_MS. Asynchronous with a hard deadline: a program that ignores SIGTERM
105
+ * is killed outright and never waited on, so it can never hold up the hub.
106
+ */
107
+ function identify(file: string, pattern: RegExp, env: Record<string, string>): Promise<string | null | false> {
108
+ let key = file;
109
+ try { key = `${file}:${statSync(file).mtimeMs}`; } catch { /* unreadable: ask anyway */ }
110
+ let found = identities.get(key);
111
+ if (!found) {
112
+ found = new Promise<string | null | false>((resolve) => {
113
+ let out = "", settled = false, child: ChildProcess | undefined;
114
+ // Its own process group, killed as one when the answer is in or the deadline passes: a descendant that kept the
115
+ // pipe open would otherwise outlive the probe and hold up whoever waits on it.
116
+ const finish = (answer: string | null | false) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ clearTimeout(timer);
120
+ try { if (child?.pid) process.kill(-child.pid, "SIGKILL"); } catch { /* the group is gone */ }
121
+ child?.stdout?.destroy();
122
+ resolve(answer);
123
+ };
124
+ const timer = setTimeout(() => finish(false), PROBE_MS);
125
+ try {
126
+ // Only PATH (its absolute entries outside the project, so an env-shebang interpreter still resolves), HOME and
127
+ // the C locale reach the probed program: no gateway keys, Access values or agent markers.
128
+ child = spawn(file, ["--version"], { detached: true, stdio: ["ignore", "pipe", "ignore"], env });
129
+ child.unref();
130
+ // A version line is short: a program that keeps writing is not the one asked for, and is not read further.
131
+ child.stdout?.on("data", (chunk: Buffer) => { out += chunk.toString("utf8"); if (out.length > PROBE_BYTES) finish(false); });
132
+ child.on("error", () => finish(false));
133
+ // The version line is written before the program exits; a moment for the pipe to deliver it.
134
+ child.on("exit", () => setTimeout(() => { const m = pattern.exec(out.trim()); finish(m ? version(m[1]) : false); }, 50));
135
+ } catch { finish(false); }
136
+ });
137
+ identities.set(key, found);
138
+ }
139
+ return found;
140
+ }
141
+
142
+ /** The add-ons a peer works with, sorted by name. Unreadable sources contribute nothing; they never throw. */
143
+ export async function detectTooling(peer: string, src: ToolingSources = {}): Promise<Addon[]> {
144
+ const home = src.home ?? homedir();
145
+ const found = new Map<AddonName, Addon>();
146
+ const add = (a: Addon) => { if (!found.has(a.name) || (found.get(a.name)!.version === null && a.version !== null)) found.set(a.name, a); };
147
+ const env = src.env ?? process.env;
148
+ if (peer === "claude") claudePlugins(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), src.cwd).forEach(add);
149
+ if (peer === "codex") codexPlugins(env.CODEX_HOME?.trim() || join(home, ".codex")).forEach(add);
150
+ const path = src.path ?? process.env.PATH ?? "";
151
+ for (const [bin, spec] of Object.entries(BINARIES)) {
152
+ const file = onPath(bin, path, src.cwd ?? process.cwd());
153
+ if (!file) continue;
154
+ // A program inside the project is the repository's, not an installed tool: it is never run to identify it (opening a
155
+ // checkout must not execute its files), so it does not count where its identity has to be checked.
156
+ const inProject = src.cwd !== undefined && within(real(file), real(src.cwd));
157
+ 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" };
158
+ const v = spec.version ? (inProject ? false : await identify(file, spec.version, probeEnv)) : null;
159
+ if (v !== false) add({ name: spec.name, version: v });
160
+ }
161
+ return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
162
+ }
163
+
164
+ /** `on` when any of the peers has the add-on, `off` when every peer's fingerprint is known and none has it, else unknown. */
165
+ export function addonState(name: AddonName, fingerprints: (Addon[] | undefined)[]): "on" | "off" | "unknown" {
166
+ if (fingerprints.some((f) => f?.some((a) => a.name === name))) return "on";
167
+ return fingerprints.length && fingerprints.every((f) => f !== undefined) ? "off" : "unknown";
168
+ }
169
+
170
+ export const isAddon = (name: string): name is AddonName => (ADDONS as readonly string[]).includes(name);
package/src/hub/ui.ts CHANGED
@@ -1,5 +1,13 @@
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";
1
5
  import { PEER_START_RETRY_MS } from "./start-mode.ts";
2
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";
3
11
  import { createHash, randomBytes } from "node:crypto";
4
12
  import { readFileSync } from "node:fs";
5
13
 
@@ -28,8 +36,12 @@ export function startDashboard(options: DashboardOptions) {
28
36
  const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
29
37
  // Inject the same pure public-board model used by the console before computing CSP hashes.
30
38
  const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
39
+ .replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
40
+ .replace("/* PEER_PALETTE_CSS */", () => peerPaletteCss())
31
41
  .replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
32
- .replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`);
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()};`);
33
45
  const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
34
46
  .map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
35
47
  const headers = {
@@ -53,7 +65,7 @@ export function startDashboard(options: DashboardOptions) {
53
65
  hostname: "127.0.0.1",
54
66
  port: 0,
55
67
  maxRequestBodySize: 32 * 1024,
56
- async fetch(req): Promise<Response> {
68
+ async fetch(req, server): Promise<Response> {
57
69
  if (req.headers.get("host") !== host) return reject(403, "forbidden host");
58
70
  const requestOrigin = req.headers.get("origin");
59
71
  if (requestOrigin !== null && requestOrigin !== origin) return reject(403, "forbidden origin");
@@ -121,6 +133,8 @@ export function startDashboard(options: DashboardOptions) {
121
133
  }
122
134
  }
123
135
  try {
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);
124
138
  return json(await options.action(input, sessionOf(cookie)));
125
139
  } catch {
126
140
  // Exceptions may quote private task text or backend details. Never echo them to the page.
@@ -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"); }
@@ -105,7 +105,7 @@ export type SandboxNetwork = boolean | { proxyPort: number };
105
105
  * toolchain and project directories, writing the project and a temp dir of their own. The allow-default profile of
106
106
  * 0.9 and earlier was removed in 0.12.0 (issue #83).
107
107
  */
108
- export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []): string {
108
+ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = [], readOnly: string[] = []): string {
109
109
  const home = homedir();
110
110
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
111
111
  const root = realPath(cwd);
@@ -138,7 +138,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
138
138
  "(version 1)",
139
139
  ...start,
140
140
  "(deny file-write*)",
141
- `(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
141
+ ...(readOnly.length ? ["(allow file-write* (literal \"/dev/null\"))"] : [`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`]),
142
142
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub. The .git and
143
143
  // .agenthub patterns tolerate the code points HFS+ ignores in every segment, the .git name itself is refused
144
144
  // at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
@@ -159,14 +159,16 @@ export interface ExecResult {
159
159
  }
160
160
 
161
161
  /** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
162
- export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal }): Promise<ExecResult> {
162
+ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal; readOnlyData?: boolean }): Promise<ExecResult> {
163
+ if (opts.readOnlyData && /\(allow file-write\* \(subpath /.test(opts.profile)) return Promise.resolve({ code: null, output: "error: data.readonly command profile contains a broad write grant" });
163
164
  if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
164
165
  if (opts.signal?.aborted) return Promise.resolve({ code: null, output: "error: command cancelled before execution" });
165
166
  return new Promise((resolve) => {
166
167
  // A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
167
168
  // an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
168
169
  const own = realPath(mkdtempSync(join(tmpdir(), "ahub-cmd-")));
169
- const sandbox = `${opts.profile}\n(allow file-read* file-write* process-exec (subpath ${q(own)}))`;
170
+ const sandbox = `${opts.profile}\n(allow file-read* ${opts.readOnlyData ? "" : "file-write* "}process-exec (subpath ${q(own)}))`;
171
+ // A path-only deny cannot contain hardlink aliases; data mode allows reads/stdout, never filesystem writes.
170
172
  const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...opts.env, TMPDIR: `${own}/` };
171
173
  // Own process group: a timeout has to take the grandchildren too, or they keep the pipes open and the project writable.
172
174
  const child = spawn(SANDBOX_EXEC, ["-p", sandbox, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
@@ -2,6 +2,7 @@ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "nod
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { realPath } from "../hub/project.ts";
4
4
  import { isHubWritePath } from "../hub/permission-mode.ts";
5
+ import { matchesPolicy } from "../hub/path-policy.ts";
5
6
  import { isDenied } from "./deny.ts";
6
7
  import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
7
8
 
@@ -17,6 +18,7 @@ const approvalError = (picked: ToolApproval, denied: string): string | undefined
17
18
  : picked === "aborted" ? "error: approval withdrawn because the turn ended; no operation was executed" : denied;
18
19
 
19
20
  export interface ToolContext {
21
+ readOnly?: string[];
20
22
  cwd: string;
21
23
  /** Extra denylist entries from config: substrings of the project-relative path. */
22
24
  deny: string[];
@@ -27,7 +29,7 @@ export interface ToolContext {
27
29
  /** Turn cancellation signal, including the execution-budget wall cap. */
28
30
  signal?: AbortSignal;
29
31
  /** Only true permits an effect; expiry and abort remain distinct from a person's denial. */
30
- permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver) => Promise<ToolApproval>;
32
+ permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string) => Promise<ToolApproval>;
31
33
  /** Trusted process status, independent of what its stdout says. */
32
34
  onProcessExit?: (code: number | null) => void;
33
35
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
@@ -44,7 +46,7 @@ const lexists = (p: string) => {
44
46
  };
45
47
 
46
48
  /** Absolute path inside cwd after resolving symlinks, or an Error. `write` also refuses .git and .agenthub. */
47
- export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string, mode: "read" | "write"): string {
49
+ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny" | "readOnly">, path: string, mode: "read" | "write"): string {
48
50
  const root = realPath(ctx.cwd);
49
51
  const abs = resolve(root, path);
50
52
  // Resolve symlinks on the deepest part that exists: a link pointing outside must not pass as an inside path.
@@ -64,6 +66,9 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string,
64
66
  // Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
65
67
  // folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
66
68
  if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
69
+ // Identity refusal is stable even when realPath names a protected hardlink alias.
70
+ if (mode === "write" && ctx.readOnly?.length && (lstatSync(real, { throwIfNoEntry: false })?.nlink ?? 1) > 1) throw new Error("hardlinked write target cannot be verified with data.readonly");
71
+ if (mode === "write" && matchesPolicy(root, path, ctx.readOnly ?? [])) throw new Error("target is protected by data.readonly");
67
72
  return real;
68
73
  }
69
74
 
@@ -142,7 +147,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
142
147
  let file = guardPath(ctx, String(a.path), "write");
143
148
  const approvedTarget = file;
144
149
  const content = String(a.content ?? "");
145
- const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file), "error: the user did not approve this write");
150
+ const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this write");
146
151
  if (refused) return refused;
147
152
  if (ctx.signal?.aborted) return "error: turn cancelled before write";
148
153
  file = guardPath(ctx, String(a.path), "write");
@@ -157,7 +162,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
157
162
  let text = readFileSync(file, "utf8");
158
163
  let count = text.split(String(a.old)).length - 1;
159
164
  if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
160
- const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file), "error: the user did not approve this edit");
165
+ const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this edit");
161
166
  if (refused) return refused;
162
167
  if (ctx.signal?.aborted) return "error: turn cancelled before edit";
163
168
  // Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
@@ -176,7 +181,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
176
181
  if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
177
182
  const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal), "error: the user did not approve this command");
178
183
  if (refused) return refused;
179
- const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
184
+ const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
180
185
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
181
186
  return `${res.output}\n(exit ${res.code})`;
182
187
  }
@@ -191,7 +196,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
191
196
  if (refused) return refused;
192
197
  }
193
198
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
194
- const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
199
+ const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
195
200
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
196
201
  return `${res.output}\n(exit ${res.code})`;
197
202
  }
@@ -27,33 +27,47 @@ export async function related(client: MemoryClient, project: string, query: stri
27
27
  return out;
28
28
  }
29
29
 
30
+ /** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
31
+ export interface NativeItem { id: string; line: string; kind?: string }
32
+ export type NativeBrief = (task: BriefTask) => NativeItem[];
33
+ export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
34
+
30
35
  /**
31
- * Task briefs: what memory already knows about a piece of work, handed over with it. Index first (search), then the
32
- * neighbourhood of the top hit (timeline); details stay in claude-mem for the receiver to fetch by id. A peer is never
33
- * shown the same observation twice in one hub run.
36
+ * Task briefs: what memory already knows about a piece of work, handed over with it. The hub's own store first (#258:
37
+ * entries one or two hops from the task's paths, symbols, issue and task, a linked fail first, superseded entries
38
+ * dropped), then claude-mem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
39
+ * the store for the receiver to fetch by id. `brief_items` caps both together. A peer is never shown the same entry
40
+ * twice in one hub run.
34
41
  */
35
42
  export class Briefs {
36
- private readonly seen = new Map<PeerId, Set<number>>();
43
+ private readonly seen = new Map<PeerId, Set<string>>();
37
44
 
38
45
  constructor(
39
- private readonly client: MemoryClient,
46
+ private readonly client: MemoryClient | undefined,
40
47
  private readonly project: string,
41
48
  private readonly maxItems = 8,
49
+ private readonly native?: NativeBrief,
50
+ /** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
51
+ private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
42
52
  ) {}
43
53
 
44
- async forTask(peer: PeerId, task: { title: string; refs: { paths?: string[] } }): Promise<string | undefined> {
45
- const found = await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" "));
46
- if (!found.length) return undefined;
47
- const seen = this.seen.get(peer) ?? new Set<number>();
54
+ async forTask(peer: PeerId, task: BriefTask): Promise<string | undefined> {
55
+ const seen = this.seen.get(peer) ?? new Set<string>();
48
56
  this.seen.set(peer, seen);
57
+ const own: NativeItem[] = [];
58
+ try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
59
+ const found = this.client && own.length < this.maxItems ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
49
60
  const items: BriefItem[] = [];
50
61
  for (const item of found) {
51
- if (seen.has(item.id)) continue;
62
+ if (seen.has(`#${item.id}`)) continue;
52
63
  items.push(item);
53
- if (items.length === this.maxItems) break;
64
+ if (own.length + items.length === this.maxItems) break;
54
65
  }
55
- if (!items.length) return undefined;
56
- for (const item of items) seen.add(item.id);
57
- return ["Memory brief (claude-mem; fetch details by id if you need them):", ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
66
+ if (!own.length && !items.length) return undefined;
67
+ for (const item of own) seen.add(item.id);
68
+ for (const item of items) seen.add(`#${item.id}`);
69
+ if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
70
+ const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; claude-mem by #id):" : "Memory brief (claude-mem; fetch details by id if you need them):";
71
+ return [header, ...own.map((i) => i.line), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
58
72
  }
59
73
  }