@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.
Files changed (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  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 +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
@@ -0,0 +1,96 @@
1
+ import { lstatSync, opendirSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { realPath } from "./project.ts";
4
+ /** Bounded project-relative exact paths/subtrees or *, ? and whole-segment ** globs. */
5
+ export function policyPaths(raw: unknown, field: string): string[] {
6
+ if (raw === undefined) return [];
7
+ if (!Array.isArray(raw) || raw.length > 64 || raw.some(p => typeof p !== "string" || !p || p.length > 200 || isAbsolute(p) || /[\[\]{}!]/.test(p) || /[\x00-\x1f\x7f]/.test(p) || p.split(/[\\/]/).includes("..") ||
8
+ (/[?*]/.test(p) && (p.includes("\\") || (p.match(/[?*]/g)?.length ?? 0) > 16 || p.split("/").some(s => s.includes("**") && s !== "**"))))) throw new Error(`${field} takes up to 64 bounded project-relative paths or *, ? and whole-segment ** globs`);
9
+ return [...new Set(raw.map(p => p.replace(/\/+/g, "/").replace(/\/$/, "") || "."))];
10
+ }
11
+ function existing(path: string): boolean { try { lstatSync(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } }
12
+ /** Resolve the deepest existing ancestor, retaining dangling links as failures. */
13
+ export function policyRealPath(path: string): string {
14
+ let ancestor = path;
15
+ while (!existing(ancestor)) { const parent = dirname(ancestor); if (parent === ancestor) throw new Error("path cannot be resolved"); ancestor = parent; }
16
+ return join(realPath(ancestor), relative(ancestor, path));
17
+ }
18
+ const beneath = (path: string, root: string) => { path = path.toLowerCase(); root = root.toLowerCase(); return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep); };
19
+ export function policyTargets(cwd: string, paths: string[]): string[] {
20
+ const root = realPath(cwd);
21
+ return [...new Set(paths.flatMap(path => { const lexical = resolve(root, path); return [lexical, policyRealPath(lexical)]; }))];
22
+ }
23
+ /** Linear-space wildcard DP, avoiding regex backtracking on untrusted paths. */
24
+ function segmentMatches(pattern: string, name: string): boolean {
25
+ const chars = [...name.toLowerCase()];
26
+ let previous = new Array<boolean>(chars.length + 1).fill(false); previous[0] = true;
27
+ for (const char of pattern.toLowerCase()) {
28
+ const next = new Array<boolean>(chars.length + 1).fill(false);
29
+ next[0] = char === "*" && previous[0]!;
30
+ for (let i = 1; i <= chars.length; i++) next[i] = char === "*" ? previous[i]! || next[i - 1]! : previous[i - 1]! && (char === "?" || char === chars[i - 1]);
31
+ previous = next;
32
+ }
33
+ return previous[chars.length]!;
34
+ }
35
+ function closure(pattern: string[], states: Set<number>): Set<number> {
36
+ const out = new Set(states);
37
+ for (const i of out) if (pattern[i] === "**") out.add(i + 1);
38
+ return out;
39
+ }
40
+ function advance(pattern: string[], states: Set<number>, name: string): Set<number> {
41
+ const next = new Set<number>();
42
+ for (const i of closure(pattern, states)) {
43
+ if (pattern[i] === "**") next.add(i);
44
+ else if (pattern[i] !== undefined && segmentMatches(pattern[i]!, name)) next.add(i + 1);
45
+ }
46
+ return closure(pattern, next);
47
+ }
48
+ function patternScope(pattern: string[], states: Set<number>, path: string, includeParents: boolean): boolean {
49
+ if (isAbsolute(path) || path === ".." || path.startsWith("../")) return false;
50
+ states = closure(pattern, states);
51
+ if (states.has(pattern.length)) return true;
52
+ for (const name of path.split("/").filter(p => p && p !== ".")) {
53
+ states = advance(pattern, states, name);
54
+ if (states.has(pattern.length)) return true; // a matched directory protects its entire subtree
55
+ if (!states.size) return false;
56
+ }
57
+ return includeParents && states.size > 0; // this scope can contain a future protected descendant
58
+ }
59
+ /** Resolve wildcard directory aliases without trusting a cached expansion. Bound all filesystem discovery. */
60
+ function globScope(root: string, lexical: string, actual: string, path: string, includeParents: boolean, budget: { entries: number }): boolean {
61
+ const pattern = path.split("/").filter(p => p && p !== ".");
62
+ const start = closure(pattern, new Set([0]));
63
+ if (patternScope(pattern, start, relative(root, lexical), includeParents) || patternScope(pattern, start, relative(root, actual), includeParents)) return true;
64
+ const visit = (dir: string, states: Set<number>, depth: number, ancestors: Set<string>): boolean => {
65
+ const real = policyRealPath(dir);
66
+ if (states.has(pattern.length)) return beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual));
67
+ if (patternScope(pattern, states, relative(real, actual), includeParents)) return true;
68
+ if (!existing(dir)) return false;
69
+ if (!beneath(real, root)) throw new Error("policy glob discovery outside project");
70
+ const info = statSync(dir);
71
+ if (!info.isDirectory()) return false;
72
+ if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
73
+ const chain = new Set(ancestors); chain.add(real);
74
+ const handle = opendirSync(dir);
75
+ try {
76
+ for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
77
+ if (++budget.entries > 4096) throw new Error("policy glob discovery unavailable");
78
+ const next = advance(pattern, states, entry.name);
79
+ if (next.size && visit(join(dir, entry.name), next, depth + 1, chain)) return true;
80
+ }
81
+ } finally { handle.closeSync(); }
82
+ return false;
83
+ };
84
+ return visit(root, start, 0, new Set());
85
+ }
86
+ /** Compare declared spelling, real targets and wildcard aliases. Unknown discovery conservatively protects. */
87
+ export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
88
+ if (!paths.length) return false;
89
+ try {
90
+ if (path.length > 4096) return true;
91
+ const root = realPath(cwd), lexical = resolve(root, path), actual = policyRealPath(lexical);
92
+ includeParents &&= !existing(lexical) || statSync(lexical).isDirectory();
93
+ const budget = { entries: 0 };
94
+ return paths.some(entry => /[?*]/.test(entry) ? globScope(root, lexical, actual, entry, includeParents, budget) : policyTargets(root, [entry]).some(target => beneath(lexical, target) || beneath(actual, target) || (includeParents && (beneath(target, lexical) || beneath(target, actual)))));
95
+ } catch { return true; }
96
+ }
@@ -0,0 +1,28 @@
1
+ /** Native peers the hub knows; membership checks share this list. */
2
+ export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
3
+ /** Optional production peers are added explicitly; legacy defaults remain stable. */
4
+ export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
5
+ export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
6
+ export type KnownPeer = typeof SUPPORTED_PEERS[number];
7
+
8
+ export const isKnownPeer = (value: unknown): value is KnownPeer =>
9
+ typeof value === "string" && (KNOWN_PEERS as readonly string[]).includes(value);
10
+
11
+ export const isSupportedPeer = (value: unknown): value is KnownPeer =>
12
+ typeof value === "string" && (SUPPORTED_PEERS as readonly string[]).includes(value);
13
+
14
+ // Policy metadata, not a membership list. Preserve DEFAULT_ROLES' existing key order.
15
+ const PEER_ROLES: Record<KnownPeer, readonly string[]> = {
16
+ claude: ["planner", "reviewer"],
17
+ codex: ["implementer"],
18
+ kimi: ["implementer", "verifier"],
19
+ local: ["implementer", "verifier"],
20
+ pi: ["implementer", "verifier"],
21
+ qwen: ["implementer", "verifier"],
22
+ jcode: ["implementer", "verifier"],
23
+ };
24
+
25
+ /** Each caller owns its role arrays; changing a project's defaults cannot change another's. */
26
+ export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
27
+ return Object.fromEntries(Object.entries(PEER_ROLES).filter(([peer]) => includeOptional || !(OPTIONAL_PEERS as readonly string[]).includes(peer)).map(([peer, roles]) => [peer, [...roles]]));
28
+ }
package/src/hub/peers.ts CHANGED
@@ -4,6 +4,8 @@ export interface DeliveryReceipt {
4
4
  id: string;
5
5
  state: "accepted" | "completed" | "failed_safe" | "needs_review";
6
6
  reason?: string;
7
+ /** A provider refused before acceptance for quota; requeue without spending poison retries while paused. */
8
+ quota?: true;
7
9
  }
8
10
 
9
11
  export interface PeerAdapter {
@@ -24,7 +26,7 @@ export interface PeerAdapter {
24
26
  /** Set by the bus. */
25
27
  onState?: (state: PeerState) => void;
26
28
  /** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
27
- onFailed?: (envs: Envelope[]) => void;
29
+ onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
28
30
  /** Correlated durable-delivery lifecycle, emitted only when the bus supplied an id. */
29
31
  onDelivery?: (receipt: DeliveryReceipt) => void;
30
32
  /** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
@@ -38,7 +40,7 @@ export abstract class BasePeer implements PeerAdapter {
38
40
  /** Returns a reason (a string) when the hub refused the message (issue #38). */
39
41
  onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
40
42
  onState?: (state: PeerState) => void;
41
- onFailed?: (envs: Envelope[]) => void;
43
+ onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
42
44
  onDelivery?: (receipt: DeliveryReceipt) => void;
43
45
  private _state: PeerState = "offline";
44
46
  private timer: ReturnType<typeof setTimeout> | undefined;
@@ -77,7 +79,7 @@ export abstract class BasePeer implements PeerAdapter {
77
79
  this.onDelivery?.(receipt);
78
80
  }
79
81
 
80
- private clearWatchdog(): void {
82
+ protected clearWatchdog(): void {
81
83
  if (this.timer) clearTimeout(this.timer);
82
84
  this.timer = undefined;
83
85
  }
@@ -1,25 +1,52 @@
1
+ import { isSupportedPeer } from "./peer-catalog.ts";
1
2
  import { lstatSync } from "node:fs";
2
- import { isAbsolute, relative, sep } from "node:path";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
4
 
4
5
  /** Operator-selected approval frequency; agent sandboxes remain separate. */
5
6
  export const PERMISSION_MODES = ["ask", "ask-when-needed", "never-ask"] as const;
6
7
  export type PermissionMode = typeof PERMISSION_MODES[number];
7
8
  export const KIMI_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "yolo", "never-ask": "auto" };
9
+ export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
8
10
  export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
9
11
  export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
10
12
  if (value === undefined) return {};
11
13
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
12
14
  const result: Record<string, PermissionMode> = {};
13
15
  for (const [peer, mode] of Object.entries(value)) {
14
- if (!["claude", "codex", "kimi", "pi", "local"].includes(peer)) throw new Error(`permission_modes.${peer}: unsupported peer`);
16
+ if (!isSupportedPeer(peer)) throw new Error(`permission_modes.${peer}: unsupported peer`);
17
+ if (peer === "jcode") throw new Error("Jcode native policy is unmanaged; no hub permission_modes.jcode mapping exists");
15
18
  if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
16
19
  result[peer] = mode;
17
20
  }
18
21
  return result;
19
22
  }
23
+ /** Approval frequency does not create a common sandbox across native peers. */
24
+ export function permissionBoundary(peer: string): string {
25
+ if (peer === "jcode") return "jcode: native policy unmanaged; NO hub sandbox; approvals belong to its native tools";
26
+ if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
27
+ if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
28
+ return `${peer}: runs its own tools; NO hub sandbox`;
29
+ }
30
+ /** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
31
+ export function permissionGrant(peer: string, mode: PermissionMode): string {
32
+ if (peer === "jcode") return "no verified hub mode mapping; native policy only";
33
+ if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
34
+ if (peer === "qwen") return mode === "never-ask" ? "Qwen yolo approves every tool; NO hub sandbox" : "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox";
35
+ if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
36
+ if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
37
+ if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
38
+ return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and edit inside the project run without asking; shell and git writes still ask";
39
+ }
20
40
  export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
21
41
  /** Native agent policy files are never included in scoped automatic file grants. */
22
- export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".pi", ".mcp.json"]);
42
+ export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".kimi-code", ".jcode", ".pi", ".mcp.json", ".agents", ".claude.json"]);
43
+ export const INSTRUCTION_FILES: ReadonlySet<string> = new Set(["agents.md", "claude.md", "qwen.md"]);
44
+ export const isInstructionPath = (path: string): boolean => path.split(/[\\/]/).some(segment => INSTRUCTION_FILES.has(foldSegment(segment)));
45
+ export function instructionProtection(value: unknown): boolean {
46
+ if (value === undefined) return false;
47
+ if (typeof value !== "boolean") throw new Error("protect_instruction_files must be a boolean");
48
+ return value;
49
+ }
23
50
  /**
24
51
  * One spelling per name, as a case-insensitive disk sees it: it opens `.mcp.j\u017Fon` (long s) as `.mcp.json`, and
25
52
  * HFS+ also ignores the default-ignorable code points (a zero-width joiner inside `.codex` is still `.codex`).
@@ -38,7 +65,8 @@ export function hardLinked(path: string): boolean {
38
65
  * inside the project, outside every agent's configuration, judged by the path within the project (a project that
39
66
  * itself lives under `.claude/worktrees/` is still a project), and not a hard link.
40
67
  */
41
- export function grantablePath(root: string, path: string): boolean {
68
+ export function grantablePath(root: string, path: string, originalPath?: string, protectInstructions = false): boolean {
42
69
  const inside = relative(root, path);
43
- return !!inside && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside) && !isAgentConfigPath(inside) && !isHubWritePath(inside) && !hardLinked(path);
70
+ const original = originalPath === undefined ? inside : relative(root, resolve(root, originalPath));
71
+ return !!inside && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside) && !isAgentConfigPath(inside) && !isHubWritePath(inside) && !isAgentConfigPath(original) && !isHubWritePath(original) && !(protectInstructions && (isInstructionPath(inside) || isInstructionPath(original))) && !hardLinked(path);
44
72
  }
@@ -0,0 +1,70 @@
1
+ import type { Task } from "./board.ts";
2
+
3
+ export const BACKLOG_SPLIT_SCREEN_TIMEOUT_MS = 8000;
4
+
5
+ export const PLANNING_FIELDS = ["rank", "priority", "release", "plannedStart", "due", "pinned", "deps", "reserved"] as const;
6
+ export interface PlanningFields {
7
+ rank?: number | null;
8
+ priority?: number | null;
9
+ release?: number | null;
10
+ plannedStart?: number | null;
11
+ due?: number | null;
12
+ pinned?: boolean;
13
+ deps?: number[];
14
+ reserved?: string | null;
15
+ }
16
+ export interface PlanningPatch extends PlanningFields {}
17
+ /** Machine-local capacity settings; ids never grant roster membership or authority. */
18
+ export function planningWipLimits(input: unknown): Record<string, number> {
19
+ if (input === undefined) return {};
20
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).length > 64) throw new Error("tasks.wip_limits must be a bounded peer map");
21
+ const limits: Record<string, number> = Object.create(null);
22
+ for (const [peer, value] of Object.entries(input)) {
23
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 32) throw new Error("tasks.wip_limits needs peer ids and integer capacities 0..32");
24
+ limits[peer] = value;
25
+ }
26
+ return limits;
27
+ }
28
+ export const RELEASE_STATES = ["planning", "active", "frozen", "shipped", "dropped"] as const;
29
+ export type ReleaseState = (typeof RELEASE_STATES)[number];
30
+ export interface Release {
31
+ id: number; name: string; goal: string; order: number; state: ReleaseState;
32
+ plannedStart: number | null; cut: number | null; ship: number | null;
33
+ activatedAt: number | null; frozenAt: number | null; shippedAt: number | null;
34
+ created: number; updated: number; revision: number;
35
+ }
36
+
37
+ /** Validate the entire planning input before a write; actual dates are never caller input. */
38
+ export function planningPatch(input: unknown): PlanningPatch {
39
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("planning fields must be an object");
40
+ const fields = input as Record<string, unknown>;
41
+ if (!Object.keys(fields).length || Object.keys(fields).some(key => !(PLANNING_FIELDS as readonly string[]).includes(key))) throw new Error("unknown or empty planning fields");
42
+ const result: PlanningPatch = {};
43
+ for (const key of PLANNING_FIELDS) {
44
+ if (!Object.hasOwn(fields, key)) continue;
45
+ const value = fields[key];
46
+ if (key === "deps") {
47
+ if (!Array.isArray(value) || value.length > 64 || value.some(id => !Number.isSafeInteger(id) || id <= 0) || new Set(value).size !== value.length) throw new Error("deps must be at most 64 unique positive task ids");
48
+ result.deps = [...value];
49
+ } else if (key === "reserved") {
50
+ const peer = typeof value === "string" ? value.trim().toLowerCase() : null;
51
+ if (value !== null && (!peer || !/^[a-z][a-z0-9-]{0,31}$/.test(peer) || ["user", "hub", "digest"].includes(peer))) throw new Error("reserved must be a valid agent peer id or null");
52
+ result.reserved = peer;
53
+ } else if (key === "pinned") {
54
+ if (typeof value !== "boolean") throw new Error("pinned must be a boolean");
55
+ result.pinned = value;
56
+ } else {
57
+ if (value !== null && (typeof value !== "number" || !Number.isSafeInteger(value) || value < (key === "release" || key === "plannedStart" || key === "due" ? 1 : 0) || (key === "priority" && value > 3) || (["plannedStart", "due"].includes(key) && value > 8_640_000_000_000_000))) throw new Error(`invalid planning field ${key}`);
58
+ result[key] = value as number | null;
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /** Structured lifecycle events only, including reviewerless approval via done. */
65
+ export function actualTaskDates(task: Pick<Task, "history" | "state" | "reviewer">) {
66
+ const first = (events: readonly string[]) => task.history.find(entry => events.includes(entry.event))?.at ?? null;
67
+ const approved = first(["approved"]), reviewed = task.reviewer !== null || task.history.some(entry => entry.event === "approved" || entry.event === "changes_requested");
68
+ const reviewerlessDone = task.state === "approved" && !task.reviewer ? [...task.history].reverse().find(entry => entry.event === "done")?.at ?? null : null;
69
+ return { startedAt: first(["accepted"]), reviewAt: reviewed ? first(["done"]) : null, doneAt: approved ?? reviewerlessDone };
70
+ }
@@ -0,0 +1,74 @@
1
+ import dev from "../../templates/profiles/dev.json";
2
+ import research from "../../templates/profiles/research.json";
3
+ import writing from "../../templates/profiles/writing.json";
4
+ import general from "../../templates/profiles/general.json";
5
+ import { ALL_PLAN_KEYS, CLASSES, type TaskClass } from "./board.ts";
6
+ import { catalogAssignments } from "./roles.ts";
7
+ import { sanitize } from "./envelope.ts";
8
+ export const PROFILE_NAMES = ["dev", "research", "writing", "general"] as const;
9
+ export type ProfileName = (typeof PROFILE_NAMES)[number];
10
+ export interface ProjectProfile {
11
+ name: string; classes: Record<string, string>; plan_keys: string[]; role_text: Record<string, string>;
12
+ catalog_roles: Record<string, string[]>; agents: string; example_checks: string[];
13
+ }
14
+ const PROFILES: Record<ProfileName, ProjectProfile> = { dev, research, writing, general };
15
+ export function profileName(raw: unknown): ProfileName {
16
+ if (raw === undefined) return "dev";
17
+ if (typeof raw !== "string" || !PROFILE_NAMES.includes(raw as ProfileName)) throw new Error(`profile must be one of ${PROFILE_NAMES.join(", ")}`);
18
+ return raw as ProfileName;
19
+ }
20
+ export const projectProfile = (raw?: unknown): ProjectProfile => PROFILES[profileName(raw)];
21
+ export type ProfileSelection = ProfileName | ProjectProfile;
22
+ export const profileData = (profile: ProfileSelection = "dev"): ProjectProfile => typeof profile === "string" ? projectProfile(profile) : profile;
23
+ export function profileClass(raw: string, profile: ProfileSelection = "dev"): TaskClass {
24
+ const data = profileData(profile);
25
+ const base = typeof raw === "string" ? (Object.hasOwn(data.classes, raw) ? data.classes[raw] : Object.hasOwn(dev.classes, raw) ? (dev.classes as Record<string, string>)[raw] : undefined) : undefined;
26
+ if (!base) throw new Error(`class must be one of ${Object.keys(data.classes).join(", ")} (profile ${data.name}; existing base classes also accepted)`);
27
+ return base as TaskClass;
28
+ }
29
+ export const profileTaskLabel = (task: { class: string; profile?: string; profileClass?: string }) => task.profileClass ? `${task.profileClass} (base ${task.class}, ${task.profile})` : task.class;
30
+ export const PROFILE_CLASS_NAMES = [...new Set(Object.values(PROFILES).flatMap(p => Object.keys(p.classes)))];
31
+
32
+ /** Pure project data resolution. No mutation of shipped profiles or executable check defaults. */
33
+ export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfile {
34
+ const original = projectProfile(raw);
35
+ if (overrides === undefined) return original;
36
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) throw new Error("profile_overrides must be an object");
37
+ const data = overrides as Record<string, unknown>, allowed = new Set(["classes", "plan_keys", "role_text", "catalog_roles", "agents", "example_checks"]);
38
+ if (Object.keys(data).some(key => !allowed.has(key))) throw new Error("unknown profile override field");
39
+ if (!Object.keys(data).length) return original;
40
+ const result: ProjectProfile = { ...original, classes: { ...original.classes }, plan_keys: [...original.plan_keys], role_text: { ...original.role_text }, catalog_roles: Object.fromEntries(Object.entries(original.catalog_roles).map(([peer, ids]) => [peer, [...ids]])), example_checks: [...original.example_checks] };
41
+ const text = (value: unknown, cap: number): string => { if (typeof value !== "string" || value.length > cap) throw new Error("profile text is missing or oversized"); return sanitize(value); };
42
+ if (data.classes !== undefined) {
43
+ if (!data.classes || typeof data.classes !== "object" || Array.isArray(data.classes) || Object.keys(data.classes).length > 32) throw new Error("profile classes must be a bounded base-class map");
44
+ for (const [name, base] of Object.entries(data.classes)) {
45
+ if (!/^[a-z][a-z0-9_]{0,47}$/.test(name) || typeof base !== "string" || !CLASSES.includes(base as TaskClass) || CLASSES.includes(name as TaskClass) && name !== base) throw new Error("profile class must retain an existing base-class meaning");
46
+ result.classes[name] = base;
47
+ }
48
+ }
49
+ if (data.plan_keys !== undefined) {
50
+ if (!Array.isArray(data.plan_keys) || !data.plan_keys.length || data.plan_keys.length > ALL_PLAN_KEYS.length || new Set(data.plan_keys).size !== data.plan_keys.length || data.plan_keys.some(key => !ALL_PLAN_KEYS.includes(key))) throw new Error("profile plan_keys must select distinct supported fields");
51
+ result.plan_keys = [...data.plan_keys];
52
+ }
53
+ if (data.role_text !== undefined) {
54
+ if (!data.role_text || typeof data.role_text !== "object" || Array.isArray(data.role_text) || Object.keys(data.role_text).length > 8) throw new Error("profile role_text must be bounded guidance");
55
+ for (const [role, value] of Object.entries(data.role_text)) {
56
+ if (!["planner", "implementer", "verifier", "reviewer", "conductor"].includes(role)) throw new Error("profile role_text must name an existing flow role");
57
+ result.role_text[role] = text(value, 2000);
58
+ }
59
+ }
60
+ if (data.catalog_roles !== undefined) result.catalog_roles = { ...result.catalog_roles, ...catalogAssignments(data.catalog_roles) };
61
+ if (data.agents !== undefined) result.agents = text(data.agents, 16000);
62
+ if (data.example_checks !== undefined) {
63
+ if (!Array.isArray(data.example_checks) || data.example_checks.length > 16) throw new Error("profile example_checks must be a bounded display list");
64
+ result.example_checks = data.example_checks.map(value => text(value, 2000));
65
+ }
66
+ return result;
67
+ }
68
+
69
+ /** Maps merge across shared/local layers; selections and display text replace as a unit. */
70
+ export function mergeProfileOverrides(a: unknown, b: unknown): Record<string, unknown> | undefined {
71
+ if (a === undefined && b === undefined) return undefined;
72
+ const left = (a ?? {}) as Record<string, unknown>, right = (b ?? {}) as Record<string, unknown>;
73
+ return { ...left, ...right, ...Object.fromEntries(["classes", "role_text", "catalog_roles"].filter(key => left[key] !== undefined && right[key] !== undefined).map(key => [key, { ...(left[key] as object), ...(right[key] as object) }])) };
74
+ }
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
+ import { hubGitSync } from "./git.ts";
5
6
 
6
7
  const canonical = (path: string): string => {
7
8
  const absolute = resolve(path);
@@ -39,7 +40,7 @@ export function realPath(path: string): string {
39
40
  }
40
41
 
41
42
  function gitRoot(dir: string): string | undefined {
42
- const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
43
+ const result = hubGitSync(["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
43
44
  if (result.status !== 0) return undefined;
44
45
  const root = result.stdout.trim();
45
46
  return root ? canonical(root) : undefined;
@@ -0,0 +1,73 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { hubGitSync } from "./git.ts";
5
+ import { sanitize } from "./envelope.ts";
6
+ import { foldSegment } from "./permission-mode.ts";
7
+ import { isDenied } from "../local/deny.ts";
8
+ import { MAX_PROJECTION_INPUT, type TextProjections } from "./text-projection.ts";
9
+ import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
10
+
11
+ export const PROJECTED_DIFF_BYTES = 12000;
12
+ const MAX_FILES = 8, DIFF_MS = 8000;
13
+ const HEADER = "Projected text only (authorship and raw restore safety are independent):";
14
+ const treeId = (tree: string) => /^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(tree);
15
+ export function projectionPath(repo: Repo, path: string, deny: string[] = []): boolean {
16
+ const parts = path.split("/"), folded = parts.map(foldSegment).join("/");
17
+ return !!path && !path.startsWith("/") && path.startsWith(repo.prefix) && parts.every(part => !!part && part !== "." && part !== ".." && ![".git", ".agenthub"].includes(foldSegment(part))) && !isDenied(path, deny) && !isDenied(folded, deny.map(foldSegment));
18
+ }
19
+ /** null is an absent file; undefined is unavailable evidence or a non-regular object. */
20
+ export function snapshotBytes(repo: Repo, tree: string, path: string, deadline = performance.now() + DIFF_MS): Buffer | null | undefined {
21
+ if (!treeId(tree) || !projectionPath(repo, path) || performance.now() >= deadline) return undefined;
22
+ const run = (args: string[], encoding: "utf8" | "latin1" = "utf8", maxBuffer = 8192) => hubGitSync(repoGitArgs(repo, args), { encoding, maxBuffer, timeout: Math.ceil(Math.max(1, Math.min(DIFF_MS, deadline - performance.now()))), env: repoGitEnv(repo) });
23
+ const entry = run(["ls-tree", "-z", tree, "--", `:(literal)${path}`]);
24
+ if (entry.status !== 0) return undefined;
25
+ if (!entry.stdout) return null;
26
+ const match = /^(100644|100755) blob ([a-f0-9]+)\t/.exec(entry.stdout);
27
+ if (!match || entry.stdout.slice(match[0].length) !== path + "\0" || !treeId(match[2]!)) return undefined;
28
+ if (performance.now() >= deadline) return undefined;
29
+ const size = run(["cat-file", "-s", match[2]!]);
30
+ const length = Number(size.stdout.trim());
31
+ if (size.status !== 0 || !Number.isSafeInteger(length) || length < 0 || length > MAX_PROJECTION_INPUT || performance.now() >= deadline) return undefined;
32
+ // Latin1 preserves all byte values through the existing bounded git wrapper.
33
+ const blob = run(["cat-file", "blob", match[2]!], "latin1", MAX_PROJECTION_INPUT + 1);
34
+ if (blob.status !== 0 || blob.stdout.length !== length) return undefined;
35
+ return Buffer.from(blob.stdout, "latin1");
36
+ }
37
+ function hunks(repo: Repo, before: string, after: string, deadline: number): string | undefined {
38
+ if (performance.now() >= deadline) return undefined;
39
+ const scratch = mkdtempSync(join(tmpdir(), "ahub-projected-diff-"));
40
+ try {
41
+ const a = join(scratch, "before"), b = join(scratch, "after"); writeFileSync(a, before, { mode: 0o600 }); writeFileSync(b, after, { mode: 0o600 });
42
+ const diff = hubGitSync(repoGitArgs(repo, ["diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--unified=2", "--", a, b]), { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: Math.ceil(Math.max(1, Math.min(DIFF_MS, deadline - performance.now()))), env: repoGitEnv(repo) });
43
+ if (diff.status !== 0 && diff.status !== 1) return undefined;
44
+ const start = diff.stdout.indexOf("@@");
45
+ if (start < 0) return diff.status === 0 ? "(projected text unchanged)" : undefined;
46
+ return diff.stdout.slice(start).replace(/\n$/, "");
47
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
48
+ }
49
+ /** Presentation of checked snapshot bytes, never evidence of authorship or restore safety. */
50
+ export function projectedDiff(options: { repo: Repo; from: string; to: string; paths: string[]; projections: TextProjections; deny?: string[]; privatePath?: (path: string) => boolean; nameable?: (text: string) => boolean }): string | undefined {
51
+ const { repo, projections } = options;
52
+ if (!projections.enabled) return undefined;
53
+ const deadline = performance.now() + DIFF_MS, rows: string[] = []; let bytes = Buffer.byteLength(HEADER) + 1, files = 0;
54
+ const add = (text: string) => { const safe = sanitize(text); if (bytes + Buffer.byteLength(safe) + 1 > PROJECTED_DIFF_BYTES) return false; rows.push(safe); bytes += Buffer.byteLength(safe) + 1; return true; };
55
+ for (const path of [...new Set(options.paths)]) {
56
+ if (!projections.handles(path) || !projectionPath(repo, path, options.deny) || options.nameable?.(path) === false) continue;
57
+ if (files++ >= MAX_FILES || performance.now() >= deadline) { add("(remaining projection previews omitted: count or time limit)"); break; }
58
+ if (options.privatePath?.(path)) { if (!add(`${path}: (projection withheld: private path)`)) break; continue; }
59
+ const before = snapshotBytes(repo, options.from, path, deadline), after = snapshotBytes(repo, options.to, path, deadline);
60
+ if (before === undefined || after === undefined) { if (!add(`${path}: (projection unavailable: snapshot file is unknown)`)) break; continue; }
61
+ if (before === null && after === null) { if (!add(`${path}: (projection unavailable: file is absent from both snapshots)`)) break; continue; }
62
+ const a = before === null ? null : projections.projectBytes(join(repo.top, path), before, deadline);
63
+ if (a === undefined) continue;
64
+ const b = after === null ? null : projections.projectBytes(join(repo.top, path), after, deadline);
65
+ if (a === undefined || b === undefined) continue; // converter failure keeps the ordinary file-only view
66
+ if (a && b && a.configHash !== b.configHash) { if (!add(`${path}: (projection unavailable: converter changed during comparison)`)) break; continue; }
67
+ const diff = hunks(repo, a?.text ?? "", b?.text ?? "", deadline);
68
+ if (diff === undefined) { if (!add(`${path}: (projection unavailable: diff could not be read)`)) break; continue; }
69
+ const text = options.nameable?.(diff) === false ? `${path}: (projection withheld: private-data pattern)` : `${path}:\n${diff}`;
70
+ if (!add(text)) { add("(remaining projection previews omitted: output limit)"); break; }
71
+ }
72
+ return rows.length ? `${HEADER}\n${rows.join("\n")}` : undefined;
73
+ }
@@ -0,0 +1,24 @@
1
+ // Trusted synchronous-boundary helper: the parent waits, this process owns and stops the converter group.
2
+ import { spawn } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ let ownedGroup: number | undefined;
5
+ const stop = () => { if (ownedGroup) try { process.kill(-ownedGroup, "SIGKILL"); } catch { /* owned group gone */ } };
6
+ // The aggregate preview deadline can expire before the converter's own timer.
7
+ process.on("SIGTERM", () => { stop(); process.exit(143); });
8
+ process.on("SIGINT", () => { stop(); process.exit(130); });
9
+ const request = JSON.parse(await new Response(Bun.stdin.stream()).text()) as { cwd: string; sandbox: string; argv: string[]; timeoutMs?: number };
10
+ const limit = 256 * 1024;
11
+ const child = spawn("/usr/bin/sandbox-exec", ["-p", request.sandbox, ...request.argv], {
12
+ cwd: request.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"],
13
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
14
+ });
15
+ ownedGroup = child.pid;
16
+ const decoder = new TextDecoder("utf-8", { fatal: true });
17
+ let stdout = "", stderrBytes = 0, outputBytes = 0, invalid = false;
18
+ const timeout = typeof request.timeoutMs === "number" && Number.isFinite(request.timeoutMs) ? Math.ceil(Math.max(1, Math.min(5000, request.timeoutMs))) : 5000;
19
+ const timer = setTimeout(() => { invalid = true; stop(); }, timeout);
20
+ child.stdout.on("data", (bytes: Buffer) => { outputBytes += bytes.length; if (outputBytes > limit) { invalid = true; stop(); } else try { stdout += decoder.decode(bytes, { stream: true }); } catch { invalid = true; stop(); } });
21
+ child.stderr.on("data", (bytes: Buffer) => { stderrBytes += bytes.length; if (stderrBytes > limit) { invalid = true; stop(); } });
22
+ child.on("exit", () => stop()); // descendants may retain the output pipe after the leader exits
23
+ child.on("error", () => { clearTimeout(timer); invalid = true; process.stdout.write(JSON.stringify({ ok: false })); });
24
+ child.on("close", code => { clearTimeout(timer); ownedGroup = undefined; try { stdout += decoder.decode(); } catch { invalid = true; } process.stdout.write(JSON.stringify({ ok: !invalid && code === 0, ...(invalid || code !== 0 ? {} : { text: stdout }) })); });
@@ -0,0 +1,31 @@
1
+ import { sanitize } from "./envelope.ts";
2
+
3
+ /** Additive recovery outcome: older clients still receive ok:false and a useful error. */
4
+ export class RecoveryDatabaseBusy extends Error {
5
+ readonly code = "database_busy";
6
+ readonly retryable = true;
7
+ constructor() {
8
+ super("database_busy: a database is busy; wait for the other connection to settle before retrying the request");
9
+ }
10
+ }
11
+
12
+ export function isDatabaseBusy(error: unknown): boolean {
13
+ if (error instanceof RecoveryDatabaseBusy) return true;
14
+ if (!error || typeof error !== "object") return false;
15
+ const value = error as { code?: unknown; errno?: unknown };
16
+ if (typeof value.code === "string") return /^(SQLITE_BUSY|SQLITE_LOCKED)(_|$)/.test(value.code);
17
+ return error instanceof Error && error.name === "SQLiteError"
18
+ && typeof value.errno === "number" && value.errno > 0 && [5, 6].includes(value.errno & 255);
19
+ }
20
+
21
+ /** Only exact legacy SQLite refusals are inferred from an authenticated hub reply. */
22
+ export function isDatabaseBusyReply(reply: any): boolean {
23
+ return reply?.ok === false && (reply.code === "database_busy"
24
+ || reply.error === "database is locked" || reply.error === "database table is locked");
25
+ }
26
+
27
+ /** Frames only: retain the throw site without carrying a native error's message. */
28
+ export function databaseBusySite(error: unknown): string[] {
29
+ const stack = error instanceof Error ? error.stack : undefined;
30
+ return (stack ?? "").split("\n").filter(line => /^\s*at /.test(line)).slice(0, 8).map(line => sanitize(line.trim()).slice(0, 400));
31
+ }
@@ -5,6 +5,7 @@ import { Database } from "bun:sqlite";
5
5
  import { hubHome } from "./project.ts";
6
6
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
7
7
 
8
+ const RUNNER_DB_BUSY_TIMEOUT_MS = 3000;
8
9
  export const operationIdPattern = /^[a-f0-9-]{36}$/;
9
10
  export function operationPath(id: string, home = hubHome()): string {
10
11
  if (!operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
@@ -111,7 +112,7 @@ export function recoveryRunner(id: string, home = hubHome()): number | "unknown"
111
112
  let db: Database | undefined;
112
113
  try {
113
114
  db = new Database(path, { readonly: true });
114
- db.run("PRAGMA busy_timeout = 3000"); // a claim in progress must not read as no runner
115
+ db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`); // a claim in progress must not read as no runner
115
116
  // A crash between creating the file and its table leaves no runner; claimRunner creates the table and claims.
116
117
  if (!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runner'").get()) return undefined;
117
118
  const row = db.query(`SELECT pid${signed(db) ? ", signature" : ""} FROM runner WHERE slot = 1`).get() as { pid: number; signature?: string | null } | null;
@@ -133,7 +134,7 @@ export function signedRunner(id: string, home = hubHome()): number | undefined {
133
134
  let db: Database | undefined;
134
135
  try {
135
136
  db = new Database(path, { readonly: true });
136
- db.run("PRAGMA busy_timeout = 3000");
137
+ db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
137
138
  if (!signed(db)) return undefined;
138
139
  const row = db.query("SELECT pid, signature FROM runner WHERE slot = 1").get() as { pid: number; signature: string | null } | null;
139
140
  if (!row?.signature || !Number.isSafeInteger(row.pid) || row.pid < 1) return undefined;
@@ -157,11 +158,11 @@ export async function stopSignedRunner(id: string, home = hubHome(), waitMs = 50
157
158
  /** An exclusive runner claim. Never steal a live or uncertain owner on resume. */
158
159
  export function claimRunner(id: string, home = hubHome()): () => void {
159
160
  const path = `${operationPath(id, home)}.runner.db`;
160
- const db = new Database(path, { create: true });
161
- chmodSync(path, 0o600);
162
161
  const nonce = randomUUID();
162
+ const db = new Database(path, { create: true });
163
163
  try {
164
- db.run("PRAGMA busy_timeout = 3000");
164
+ db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
165
+ chmodSync(path, 0o600);
165
166
  db.run("CREATE TABLE IF NOT EXISTS runner (slot INTEGER PRIMARY KEY, pid INTEGER NOT NULL, nonce TEXT NOT NULL, signature TEXT)");
166
167
  if (!signed(db)) {
167
168
  try { db.run("ALTER TABLE runner ADD COLUMN signature TEXT"); }
@@ -7,6 +7,7 @@ import { processLiveness } from "../pi/process-signature.ts";
7
7
 
8
8
  const BASE_PORT = 4600;
9
9
  const STRIDE = 10;
10
+ export const REGISTRY_DB_BUSY_TIMEOUT_MS = 5000;
10
11
  const MAX_PORT = 65535 - 3;
11
12
  const LEGACY_DONE = "legacy_ports_imported";
12
13
  type Tx = { immediate(): void };
@@ -50,7 +51,7 @@ export class Registry {
50
51
  this.path = path;
51
52
  mkdirSync(dirname(path), { recursive: true });
52
53
  this.db = new Database(path, { create: true });
53
- this.db.run("PRAGMA busy_timeout = 5000");
54
+ this.db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
54
55
  try {
55
56
  this.db.run("PRAGMA journal_mode = WAL");
56
57
  } catch (error) {