@staix/agent-hub 0.12.21 → 0.12.23

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 (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
package/src/local/deny.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { basename, sep } from "node:path";
1
+ import { basename, dirname, sep } from "node:path";
2
2
 
3
3
  /**
4
4
  * The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
@@ -10,7 +10,6 @@ export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/arc
10
10
  export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
11
11
 
12
12
  const hasSegment = (rel: string, seg: string) => `/${rel}/`.includes(`/${seg}/`);
13
- export { hasSegment };
14
13
 
15
14
  /** True when a path names something the worker must never read, write or report. `extra`: substrings from `local.deny`. */
16
15
  export function isDenied(path: string, extra: string[] = []): boolean {
@@ -27,6 +26,76 @@ const sbplEscape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
26
  */
28
27
  export const sbplString = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
29
28
 
29
+ /**
30
+ * The 16 code points HFS+ ignores in file names: U+200C-200F, U+202A-202E, U+206A-206F, U+FEFF (a probe found
31
+ * case-insensitive APFS ignores none). `mkdir .g<U+200D>it` on HFS+ makes what everything afterwards opens as
32
+ * `.git` (#270). One source for the seatbelt profile's folded patterns; the file tools' `foldSegment` removes
33
+ * every default-ignorable, a superset of these.
34
+ */
35
+ export const HFS_IGNORABLE_POINTS = ["\u200C", "\u200D", "\u200E", "\u200F", "\u202A", "\u202B", "\u202C", "\u202D", "\u202E", "\u206A", "\u206B", "\u206C", "\u206D", "\u206E", "\u206F", "\uFEFF"];
36
+
37
+ /**
38
+ * One HFS+-ignorable code point as a seatbelt regex atom. A group alternation, not a character class: the
39
+ * seatbelt regex engine is byte-oriented, so a quantifier after a multi-byte class member binds to its last
40
+ * byte and a class never matches the plain name (verified with the real sandbox-exec, #270).
41
+ */
42
+ const HFS_IGNORABLE = `(${HFS_IGNORABLE_POINTS.join("|")})`;
43
+
44
+ /** A regex source matching `name` with any number of HFS+-ignorable code points interleaved, at the ends included. */
45
+ const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) => sbplEscape(c)).join(`${HFS_IGNORABLE}*`)}${HFS_IGNORABLE}*`;
46
+
47
+ /**
48
+ * Seatbelt rules for the hub's and git's own names (#270). `.agenthub` is refused anywhere under the project root.
49
+ * `.git` is refused at any depth, the name itself included, so a planted repository cannot arrive by creation,
50
+ * rename, symlink or gitfile, and `config` and `hooks` directly under it are folded too; the cost is that
51
+ * `git init`, `git clone` and `git worktree add` no longer run inside the sandbox. `commondir` is refused at any
52
+ * depth below a `.git` and below each external git dir: that is where git keeps it for linked worktrees and
53
+ * submodules. A submodule's git dir under `.git/modules/` gets the same `config`/`hooks` refusal at whatever depth
54
+ * submodules nest (a nested one lives under `.git/modules/<a>/modules/<b>`, #281), anchored at the discovered
55
+ * modules root so a directory named `refs`, `config` or `hooks` ABOVE the project neither disables the rule nor
56
+ * trips it; a `/refs/` or `/logs/` segment below the root keeps a branch or tag named `config` or `hooks` writable
57
+ * (the round-3 regression of #277), at the price of leaving a submodule whose own path holds a `refs` or `logs`
58
+ * segment unprotected. The basenames are folded; a folded INTERMEDIATE component on HFS+ (a spelling of `modules`)
59
+ * is a known residual, the same one the external git dir rules carry. The directory entries above those files are
60
+ * protected as the `.git` name is: the discovered git dirs, the modules root and every component in between are
61
+ * refused as targets (rename, replace, symlink, recreate). An external git dir's `config`/`hooks` rules cover only
62
+ * its own children (plus the hooks directory's contents): at any depth they would deny refs named `config` or
63
+ * `hooks` in a worktree or submodule project (`git branch fix/config` fails on `.git/logs/refs/heads/fix/config`).
64
+ * Every pattern keeps its `^` anchor (an unanchored starred alternation does not match in this engine) and no
65
+ * folded literal contains the root: an SBPL string literal dies at 1024 bytes ("Error reading string"), which a
66
+ * long project path would trip. ponytail: the modules rules do contain the (plain) modules root; a project path
67
+ * over roughly 600 bytes with submodules would exceed the limit — fold the root's segments if one ever shows up.
68
+ */
69
+ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit?: { root: string; entries: string[] }): string[] {
70
+ const git = hfsFoldedName(".git");
71
+ const re = (s: string) => `(regex ${sbplString(s)})`;
72
+ const external = gitDirs.flatMap((d) => [
73
+ `(require-all ${re(`^${sbplEscape(d)}/[^/]+$`)} ${re(`^.*/(${hfsFoldedName("config")}|${hfsFoldedName("hooks")})$`)})`,
74
+ `(subpath ${sbplString(`${d}/hooks`)})`,
75
+ `(require-all (subpath ${sbplString(d)}) ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
76
+ ]);
77
+ const modules = moduleGit
78
+ ? [
79
+ // Every name matches whole components only: `(.*/)?` forces a boundary, so a submodule named `catalogs`
80
+ // or `prefs` keeps the refusal and one named `webhooks` keeps its ordinary writes (#281 round 2).
81
+ `(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("config")}$`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
82
+ `(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("hooks")}(/|$)`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
83
+ // A submodule git dir as a directory entry: no rename, replace, symlink or recreate. The basename is
84
+ // folded (HFS+ opens the folded spelling as the real entry); the plain prefix is the path as discovered.
85
+ ...moduleGit.entries.map((d) => re(`^${sbplEscape(dirname(d))}/${hfsFoldedName(basename(d))}$`)),
86
+ ]
87
+ : [];
88
+ return [
89
+ `(require-all (subpath ${sbplString(root)}) ${re(`^.*/${hfsFoldedName(".agenthub")}(/|$)`)})`,
90
+ re(`^.*/${git}$`),
91
+ re(`^.*/${git}/${hfsFoldedName("config")}$`),
92
+ re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
93
+ `(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
94
+ ...modules,
95
+ ...external,
96
+ ];
97
+ }
98
+
30
99
  /**
31
100
  * Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
32
101
  * anchored under the project root: a bare "private/" would otherwise match /private/var/... and deny the whole temp tree.
@@ -1,9 +1,10 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readdirSync, rmSync, type Dirent } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { realPath } from "../hub/project.ts";
6
- import { denyRegexes, sbplString } from "./deny.ts";
6
+ import { hubGitSync } from "../hub/git.ts";
7
+ import { denyRegexes, hubWriteRegexes, sbplString } from "./deny.ts";
7
8
 
8
9
  const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
9
10
  export const OUTPUT_CAP = 20_000;
@@ -40,12 +41,47 @@ function developerDir(): string | undefined {
40
41
 
41
42
  /** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
42
43
  function externalGitDirs(root: string): string[] {
43
- const out = spawnSync("git", ["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
44
+ // Runs in the hub process, outside the sandbox: the hardened helper keeps the repo's config from naming a program (#281).
45
+ const out = hubGitSync(["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
44
46
  if (out.status !== 0) return [];
45
47
  const dirs = out.stdout.trim().split("\n").map((d) => resolve(root, d));
46
48
  return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
47
49
  }
48
50
 
51
+ /**
52
+ * The project's submodule git directories live under the common dir's `modules/` tree (the worktree case included:
53
+ * it is the main repo's `.git/modules`). Returns the modules root and every directory entry that must not be
54
+ * renamed, replaced, symlinked or recreated: each discovered git dir (a directory holding HEAD and config) and
55
+ * every component between it and the modules root (#281). Discovered in the hub process, outside the sandbox.
56
+ */
57
+ function moduleGitDirs(root: string): { root: string; entries: string[] } | undefined {
58
+ const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
59
+ if (out.status !== 0) return undefined;
60
+ const modulesRoot = join(resolve(root, out.stdout.trim()), "modules");
61
+ const dirs: string[] = [];
62
+ const walk = (dir: string, depth: number): void => {
63
+ if (depth > 8) return; // submodules do not nest deeper in practice; the regex rules still cover the names
64
+ let entries: Dirent[];
65
+ try {
66
+ entries = readdirSync(dir, { withFileTypes: true });
67
+ } catch {
68
+ return; // no modules tree (no submodules): nothing to discover
69
+ }
70
+ const names = new Set(entries.map((e) => e.name));
71
+ if (names.has("HEAD") && names.has("config")) {
72
+ dirs.push(dir);
73
+ const nested = join(dir, "modules");
74
+ if (names.has("modules")) walk(nested, depth + 1); // a submodule of a submodule
75
+ return;
76
+ }
77
+ for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
78
+ };
79
+ walk(modulesRoot, 0);
80
+ const entries = new Set<string>([modulesRoot]);
81
+ for (const d of dirs) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
82
+ return { root: modulesRoot, entries: [...entries] };
83
+ }
84
+
49
85
  /** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
50
86
  const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
51
87
  /**
@@ -74,6 +110,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
74
110
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
75
111
  const root = realPath(cwd);
76
112
  const gitDirs = externalGitDirs(root);
113
+ const moduleGit = moduleGitDirs(root);
77
114
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
78
115
  const dev = developerDir();
79
116
  const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
@@ -102,8 +139,12 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
102
139
  ...start,
103
140
  "(deny file-write*)",
104
141
  `(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
105
- // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
106
- `(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
142
+ // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub. The .git and
143
+ // .agenthub patterns tolerate the code points HFS+ ignores in every segment, the .git name itself is refused
144
+ // at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
145
+ // .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
146
+ // hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
147
+ `(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit).join(" ")})`,
107
148
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
108
149
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
109
150
  // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
@@ -7,6 +7,15 @@ import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
7
7
 
8
8
  export { isDenied };
9
9
 
10
+ export type ToolApproval = boolean | "expired" | "aborted";
11
+ export interface ApprovalProvenance { source: "person" | "automatic" | "expired" | "aborted"; answerEpoch: number; eligibleExpiry?: boolean; }
12
+ export type ApprovalObserver = (provenance: ApprovalProvenance) => void;
13
+ export const APPROVAL_EXPIRED_TEXT = "error: approval expired: no person answered; do not retry this call; hand the task off or stop";
14
+ export class ApprovalWaitStop extends Error {}
15
+ const approvalError = (picked: ToolApproval, denied: string): string | undefined => picked === true ? undefined
16
+ : picked === "expired" ? APPROVAL_EXPIRED_TEXT
17
+ : picked === "aborted" ? "error: approval withdrawn because the turn ended; no operation was executed" : denied;
18
+
10
19
  export interface ToolContext {
11
20
  cwd: string;
12
21
  /** Extra denylist entries from config: substrings of the project-relative path. */
@@ -17,8 +26,10 @@ export interface ToolContext {
17
26
  sandboxEnv?: Record<string, string>;
18
27
  /** Turn cancellation signal, including the execution-budget wall cap. */
19
28
  signal?: AbortSignal;
20
- /** Ask the console. Resolves false on deny or timeout. */
21
- permit: (title: string, tool?: string, signal?: AbortSignal, path?: string) => Promise<boolean>;
29
+ /** 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>;
31
+ /** Trusted process status, independent of what its stdout says. */
32
+ onProcessExit?: (code: number | null) => void;
22
33
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
23
34
  send: (text: string, to?: string[]) => string;
24
35
  }
@@ -131,7 +142,8 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
131
142
  let file = guardPath(ctx, String(a.path), "write");
132
143
  const approvedTarget = file;
133
144
  const content = String(a.content ?? "");
134
- if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file))) return "error: the user did not approve this write";
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");
146
+ if (refused) return refused;
135
147
  if (ctx.signal?.aborted) return "error: turn cancelled before write";
136
148
  file = guardPath(ctx, String(a.path), "write");
137
149
  if (file !== approvedTarget) return "error: path target changed during approval; inspect before retrying";
@@ -145,7 +157,8 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
145
157
  let text = readFileSync(file, "utf8");
146
158
  let count = text.split(String(a.old)).length - 1;
147
159
  if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
148
- if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file))) return "error: the user did not approve this edit";
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");
161
+ if (refused) return refused;
149
162
  if (ctx.signal?.aborted) return "error: turn cancelled before edit";
150
163
  // Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
151
164
  file = guardPath(ctx, String(a.path), "write");
@@ -161,8 +174,10 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
161
174
  if (!command.trim()) return "error: empty command";
162
175
  // The approver sees the whole command, not a prefix: what is hidden cannot be approved.
163
176
  if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
164
- if (!(await ctx.permit(`bash: ${command}`, "bash", ctx.signal))) return "error: the user did not approve this command";
177
+ const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal), "error: the user did not approve this command");
178
+ if (refused) return refused;
165
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 } : {}) });
180
+ try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
166
181
  return `${res.output}\n(exit ${res.code})`;
167
182
  }
168
183
  case "git": {
@@ -171,9 +186,13 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
171
186
  if (!GIT_READ.has(sub) && !GIT_WRITE.has(sub)) return `error: git ${sub} is not available to the local worker`;
172
187
  const problem = gitArgsProblem(args, ctx.deny);
173
188
  if (problem) return `error: git ${sub}: ${problem}`;
174
- if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`, "git", ctx.signal))) return "error: the user did not approve this git command";
189
+ if (GIT_WRITE.has(sub)) {
190
+ const refused = approvalError(await ctx.permit(`git ${args.join(" ")}`, "git", ctx.signal), "error: the user did not approve this git command");
191
+ if (refused) return refused;
192
+ }
175
193
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
176
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 } : {}) });
195
+ try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
177
196
  return `${res.output}\n(exit ${res.code})`;
178
197
  }
179
198
  case "hub_send":
@@ -1,7 +1,7 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { basename, dirname, resolve } from "node:path";
3
2
  import { readFileSync } from "node:fs";
4
3
  import type { PeerId } from "../hub/envelope.ts";
4
+ import { hubGitSync } from "../hub/git.ts";
5
5
  import type { MemoryClient } from "./client.ts";
6
6
 
7
7
  const PLATFORMS = ["claude", "codex", "kimi", "agent-hub"];
@@ -14,7 +14,7 @@ const HEADER =
14
14
 
15
15
  /** Project names from the outermost superproject down to this repo. */
16
16
  export function projectChain(cwd: string): string[] {
17
- const git = (dir: string, arg: string) => spawnSync("git", ["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
17
+ const git = (dir: string, arg: string) => hubGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
18
18
  const top = git(cwd, "--show-toplevel");
19
19
  if (!top) return [basename(cwd)];
20
20
 
package/src/models/mlx.ts CHANGED
@@ -73,6 +73,7 @@ export interface ProcessSignature {
73
73
  const DEFAULT_RUNTIME_DIR = join(homedir(), ".agenthub", "runtimes", "mlx");
74
74
  const DEFAULT_MODEL_PATH = join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
75
75
  const OWNER_FILE = "owner.json";
76
+ const GENERATION_DB_BUSY_TIMEOUT_MS = 100;
76
77
  const GENERATION_DB = "generation-slots.db";
77
78
 
78
79
  const ownerPath = (runtimeDir: string) => join(runtimeDir, OWNER_FILE);
@@ -185,7 +186,7 @@ function generationDatabase(runtimeDir: string): Database {
185
186
  mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
186
187
  const db = new Database(generationDbPath(runtimeDir), { create: true });
187
188
  try {
188
- db.run("PRAGMA busy_timeout = 100");
189
+ db.run(`PRAGMA busy_timeout = ${GENERATION_DB_BUSY_TIMEOUT_MS}`);
189
190
  db.run(`CREATE TABLE IF NOT EXISTS generation_slots (
190
191
  slot INTEGER PRIMARY KEY,
191
192
  token TEXT NOT NULL,
@@ -19,6 +19,7 @@ let pollStopped = false;
19
19
  let toolSteps = 0;
20
20
  let lastActivity = 0;
21
21
  let usageSeq = 0;
22
+ let shellSeq = 0;
22
23
  let forcedFailure = "";
23
24
  let turnGeneration = 0;
24
25
  let sessionId = "";
@@ -33,9 +34,13 @@ async function post(path: string, body: unknown): Promise<any> {
33
34
  if (!response.ok) throw new Error(`Pi bridge HTTP ${response.status}`);
34
35
  return response.json();
35
36
  }
36
- async function admitBudget(unit: BudgetUnit, idleUserBash = false): Promise<{ allowed: boolean; reservation?: string }> {
37
+ async function admitBudget(unit: BudgetUnit, idleUserBash = false, generation = turnGeneration, callSessionId = sessionId): Promise<{ allowed: boolean; reservation?: string; reason?: string }> {
38
+ const current = () => generation === turnGeneration && callSessionId === sessionId;
39
+ const stale = () => ({ allowed: false, reason: "Pi tool lineage ended during admission; do not retry the old call" });
40
+ if (!current()) return stale();
37
41
  try {
38
- const result = await post("/budget", { unit, generation: turnGeneration, ...(idleUserBash ? { idleUserBash: true } : {}) });
42
+ const result = await post("/budget", { unit, generation, ...(idleUserBash ? { idleUserBash: true } : {}) });
43
+ if (!current()) return stale();
39
44
  const decisions = Array.isArray(result?.decisions) ? result.decisions : [];
40
45
  const denied = decisions.find((item: any) => item?.allowed === false);
41
46
  if (!denied) return { allowed: true, ...(idleUserBash && typeof result?.reservation === "string" ? { reservation: result.reservation } : {}) };
@@ -43,12 +48,14 @@ async function admitBudget(unit: BudgetUnit, idleUserBash = false): Promise<{ al
43
48
  if (idleUserBash) return { allowed: false };
44
49
  forcedFailure = reason;
45
50
  } catch (error) {
51
+ if (!current()) return stale();
46
52
  if (idleUserBash) return { allowed: false };
47
53
  forcedFailure = `execution budget admission unavailable: ${(error as Error).message}`;
48
54
  }
49
- try { await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: forcedFailure }); } catch { /* abort remains authoritative when the bridge is unavailable */ }
50
- runtimeCtx?.abort?.();
51
- return { allowed: false };
55
+ const reason = forcedFailure;
56
+ try { await post("/event", { type: "agent_end", generation, failed: true, error: reason }); } catch { /* abort remains authoritative when the bridge is unavailable */ }
57
+ if (current()) runtimeCtx?.abort?.();
58
+ return { allowed: false, reason };
52
59
  }
53
60
  async function poll(pi: ExtensionAPI): Promise<void> {
54
61
  while (!pollStopped) {
@@ -77,10 +84,13 @@ async function poll(pi: ExtensionAPI): Promise<void> {
77
84
  shutdown?.();
78
85
  } else if (command.type === "abort_budget") {
79
86
  if (Number.isSafeInteger(command.generation) && command.generation === turnGeneration) {
80
- const reason = "execution budget exhausted: elapsed_ms wall cap reached";
87
+ if (typeof runtimeCtx?.abort !== "function") throw new Error("Pi runtime cannot abort the current turn");
88
+ const generation = turnGeneration, callSessionId = sessionId;
89
+ const reason = command.cause === "approval" && typeof command.reason === "string" && command.reason
90
+ ? command.reason : "execution budget exhausted: elapsed_ms wall cap reached";
81
91
  forcedFailure = reason;
82
- await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: reason });
83
- runtimeCtx?.abort?.();
92
+ await post("/event", { type: "agent_end", generation, failed: true, error: reason });
93
+ if (generation === turnGeneration && callSessionId === sessionId) runtimeCtx?.abort?.();
84
94
  }
85
95
  }
86
96
  await post("/ack", { id: command.id, ok: true });
@@ -107,7 +117,7 @@ export default function(pi: ExtensionAPI): void {
107
117
  const state = ctx.sessionManager.getHeader();
108
118
  sessionId = String(state?.id ?? "");
109
119
  try {
110
- const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile() });
120
+ const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile(), approvalTurnAbort: typeof ctx.abort === "function" });
111
121
  if (claimed?.ok === false) { ctx.shutdown?.(); return; }
112
122
  } catch (error) { ctx.shutdown?.(); throw error; }
113
123
  if (!pollStarted) { pollStarted = true; void poll(pi); }
@@ -140,8 +150,10 @@ export default function(pi: ExtensionAPI): void {
140
150
  });
141
151
  pi.on("before_provider_request", async (_event: any, ctx: any) => {
142
152
  if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return undefined; }
143
- try { if (!(await admitBudget("model_calls")).allowed) ctx.abort?.(); }
144
- catch (error) { forcedFailure = `execution budget admission failed: ${(error as Error).message}`; ctx.abort?.(); }
153
+ const generation = turnGeneration, callSessionId = sessionId;
154
+ const current = () => generation === turnGeneration && callSessionId === sessionId;
155
+ try { if (!(await admitBudget("model_calls", false, generation, callSessionId)).allowed && current()) ctx.abort?.(); }
156
+ catch (error) { if (current()) { forcedFailure = `execution budget admission failed: ${(error as Error).message}`; ctx.abort?.(); } }
145
157
  return undefined;
146
158
  });
147
159
  pi.on("agent_settled", async (_event: any, ctx: any) => {
@@ -151,10 +163,11 @@ export default function(pi: ExtensionAPI): void {
151
163
  await post("/event", { type: "agent_settled", generation: turnGeneration, ...(text ? { text } : {}) });
152
164
  });
153
165
  pi.on("user_bash", async (event: any) => {
154
- const admission = await admitBudget("tool_calls", true);
166
+ const generation = turnGeneration, callSessionId = sessionId;
167
+ const admission = await admitBudget("tool_calls", true, generation, callSessionId);
155
168
  if (!admission.allowed || !admission.reservation) return { cancel: true };
156
- const result = await post("/tool", { name: "bash", args: { command: event.command, cwd: event.cwd }, toolCallId: `pi-shell-${Date.now()}`, purpose: "idle_user_bash", generation: turnGeneration, reservation: admission.reservation });
157
- return { result: { output: String(result.text ?? result), exitCode: 0, cancelled: false, truncated: false } };
169
+ const result = await post("/tool", { name: "bash", args: { command: event.command, cwd: event.cwd }, toolCallId: `pi-shell-${Date.now()}-${++shellSeq}`, purpose: "idle_user_bash", generation, sessionId: callSessionId, reservation: admission.reservation });
170
+ return { result: { output: String(result.text ?? result), exitCode: typeof result.exitCode === "number" ? result.exitCode : undefined, cancelled: result.exitCode === null, truncated: false } };
158
171
  });
159
172
  // Managed sessions may only be handed over by PiPeer after it has fenced the
160
173
  // owner and verified the replacement identity. User /fork and /resume would
@@ -164,8 +177,11 @@ export default function(pi: ExtensionAPI): void {
164
177
  pi.on("session_shutdown", async () => { pollStopped = true; await post("/event", { type: "session_shutdown" }); });
165
178
  for (const raw of (() => { try { return JSON.parse(process.env.AGENTHUB_PI_TOOLS ?? "[]") as any[]; } catch { return []; } })()) {
166
179
  if (!raw || typeof raw.name !== "string" || !raw.parameters) continue;
167
- pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown) {
168
- if (!(await admitBudget("tool_calls")).allowed) return { content: [{ type: "text", text: `error: ${forcedFailure}` }], details: {}, isError: true };
180
+ pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown, signal?: AbortSignal) {
181
+ const generation = turnGeneration, callSessionId = sessionId;
182
+ if (signal?.aborted) return { content: [{ type: "text", text: "error: tool call cancelled before execution" }], details: {}, isError: true };
183
+ const admission = await admitBudget("tool_calls", false, generation, callSessionId);
184
+ if (!admission.allowed) return { content: [{ type: "text", text: `error: ${admission.reason ?? forcedFailure}` }], details: {}, isError: true };
169
185
  if (toolSteps++ >= maxSteps) {
170
186
  const reason = `Pi tool step limit ${maxSteps} reached`;
171
187
  forcedFailure = reason;
@@ -173,12 +189,19 @@ export default function(pi: ExtensionAPI): void {
173
189
  // event, so the adapter binds it while the turn still owns it. The counter already counts the
174
190
  // rejected pre-effect invocation (toolSteps++ above); its side effect never executes. The
175
191
  // reason text stays the failure record; the signal adds the validated counts.
176
- try { await post("/event", { type: "ceiling", kind: PI_CEILING_KIND, unit: PI_CEILING_UNIT, count: toolSteps, limit: maxSteps, sessionId, generation: turnGeneration }); } catch { /* the agent_end failure below remains authoritative */ }
177
- await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: reason });
178
- runtimeCtx?.abort?.();
192
+ try { await post("/event", { type: "ceiling", kind: PI_CEILING_KIND, unit: PI_CEILING_UNIT, count: toolSteps, limit: maxSteps, sessionId: callSessionId, generation }); } catch { /* the agent_end failure below remains authoritative */ }
193
+ await post("/event", { type: "agent_end", generation, failed: true, error: reason });
194
+ if (generation === turnGeneration && callSessionId === sessionId) runtimeCtx?.abort?.();
179
195
  return { content: [{ type: "text", text: `error: ${reason}` }], details: {}, isError: true };
180
196
  }
181
- const result = await post("/tool", { name: raw.name, args: params, toolCallId });
197
+ const aborted = () => { void post("/event", { type: "tool_abort", sessionId: callSessionId, generation, toolCallId }).catch(() => undefined); };
198
+ if (signal?.aborted) return { content: [{ type: "text", text: "error: tool call cancelled before execution" }], details: {}, isError: true };
199
+ signal?.addEventListener("abort", aborted, { once: true });
200
+ let result: any;
201
+ try {
202
+ result = await post("/tool", { name: raw.name, args: params, toolCallId, sessionId: callSessionId, generation });
203
+ if (signal?.aborted) aborted();
204
+ } finally { signal?.removeEventListener("abort", aborted); }
182
205
  const text = String(result.text ?? result);
183
206
  // Pi 1.0.1 reads isError === true alone (#181). The bridge computes `failed` with the
184
207
  // managed-tool failure contract (toolResultFailed); the extension never re-parses the text.
@@ -2,6 +2,23 @@ import { chmodSync } from "node:fs";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { createHash } from "node:crypto";
4
4
  import { PreEffectToolRefusal } from "../hub/tool-refusal.ts";
5
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
6
+
7
+ export const PI_TOOL_RESULT_CAP = 100_000;
8
+ /** Bound encoded text before wrapping it so escaping never cuts off trusted exit metadata. */
9
+ export function packPiShellResult(text: string, exitCode: number | null): string {
10
+ const pack = (value: string) => JSON.stringify({ kind: "user-bash-result", text: value, exitCode });
11
+ const full = pack(text);
12
+ if (full.length <= PI_TOOL_RESULT_CAP) return full;
13
+ const suffix = "\n[output truncated]";
14
+ let low = 0, high = text.length;
15
+ while (low < high) {
16
+ const middle = Math.ceil((low + high) / 2);
17
+ if (pack(text.slice(0, middle) + suffix).length <= PI_TOOL_RESULT_CAP) low = middle;
18
+ else high = middle - 1;
19
+ }
20
+ return pack(text.slice(0, low) + suffix);
21
+ }
5
22
 
6
23
  /** Private, session-scoped dedupe. An interrupted effect is never automatically repeated. */
7
24
  export class PiToolReceipts {
@@ -11,8 +28,8 @@ export class PiToolReceipts {
11
28
  private readonly pending = new Map<string, Promise<string>>();
12
29
  constructor(file: string) {
13
30
  this.db = new Database(file, { create: true });
31
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
14
32
  chmodSync(file, 0o600);
15
- this.db.run("PRAGMA busy_timeout = 5000");
16
33
  this.db.run(`CREATE TABLE IF NOT EXISTS pi_tool_receipts (
17
34
  session TEXT NOT NULL, call_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
18
35
  state TEXT NOT NULL, result TEXT, PRIMARY KEY(session, call_id))`);
@@ -33,7 +50,7 @@ export class PiToolReceipts {
33
50
  const claim = this.db.query("INSERT OR IGNORE INTO pi_tool_receipts(session,call_id,fingerprint,state) VALUES(?,?,?,'pending')").run(session, callId, fingerprint);
34
51
  if (!claim.changes) return this.execute(session, callId, name, args, run);
35
52
  const settle = (result: string): string => {
36
- const text = result.slice(0, 100_000);
53
+ const text = result.slice(0, PI_TOOL_RESULT_CAP);
37
54
  this.db.query("UPDATE pi_tool_receipts SET state='done',result=? WHERE session=? AND call_id=?").run(text, session, callId);
38
55
  return text;
39
56
  };