@staix/agent-hub 0.17.0 → 0.19.0

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 (109) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -6
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +11 -2
  9. package/docs/agent-notes/tests.md +7 -3
  10. package/docs/dogfood.md +22 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +162 -35
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +15 -5
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +44 -13
  17. package/docs/smoke.md +3 -3
  18. package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
  19. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  20. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  21. package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
  22. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  23. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  24. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  25. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  26. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  27. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  28. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  29. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  30. package/docs/verification/claude-permission-transport-native.py +187 -0
  31. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  32. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  33. package/docs/verification/pi-140-model-free.ts +57 -0
  34. package/docs/verification/pi-140-native.mjs +21 -0
  35. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  36. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  37. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  38. package/docs/verified.json +24 -18
  39. package/package.json +2 -2
  40. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  41. package/plugins/agent-hub/server.js +364 -98
  42. package/src/adapters/agy/entry.ts +1 -1
  43. package/src/adapters/claude-channel.ts +7 -3
  44. package/src/adapters/claude-implementer.ts +5 -3
  45. package/src/adapters/compat.ts +32 -18
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  48. package/src/adapters/definitions/native-factories.ts +7 -2
  49. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  50. package/src/adapters/local-worker.ts +5 -3
  51. package/src/adapters/pi.ts +39 -3
  52. package/src/adapters/qwen-dual.ts +25 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +1 -0
  55. package/src/cli/agents.ts +69 -7
  56. package/src/cli/claude-shell-wrapper.ts +54 -0
  57. package/src/cli/console-state.ts +9 -2
  58. package/src/cli/help.ts +29 -28
  59. package/src/cli/identity.ts +87 -6
  60. package/src/cli/main.ts +198 -71
  61. package/src/cli/output.ts +11 -2
  62. package/src/cli/sdlc-output.ts +242 -0
  63. package/src/cli/task-output.ts +212 -0
  64. package/src/cli/upgrade-output.ts +57 -0
  65. package/src/hub/agent-drift.ts +61 -0
  66. package/src/hub/board.ts +31 -2
  67. package/src/hub/check-runner.ts +385 -25
  68. package/src/hub/claude-shell-wrapper.ts +349 -0
  69. package/src/hub/conductor.ts +5 -0
  70. package/src/hub/config-apply.ts +16 -7
  71. package/src/hub/config-edit-host.ts +1 -1
  72. package/src/hub/config-edit.ts +2 -2
  73. package/src/hub/config-tier.ts +3 -3
  74. package/src/hub/daemon.ts +530 -87
  75. package/src/hub/dogfood-profile.ts +248 -0
  76. package/src/hub/dogfood-read.ts +77 -14
  77. package/src/hub/dogfood.ts +5 -1
  78. package/src/hub/events.ts +1 -1
  79. package/src/hub/host-executable.ts +21 -1
  80. package/src/hub/hub-tools.ts +2 -2
  81. package/src/hub/instances.ts +18 -2
  82. package/src/hub/lifecycle.ts +1 -1
  83. package/src/hub/operational-authority.ts +67 -0
  84. package/src/hub/operational-view.ts +137 -0
  85. package/src/hub/orphan-process.ts +69 -0
  86. package/src/hub/permission-mode.ts +1 -1
  87. package/src/hub/read-only-shell-proof.ts +81 -0
  88. package/src/hub/read-only-shell.ts +162 -0
  89. package/src/hub/review-scope.ts +13 -0
  90. package/src/hub/reviewer-pool-config.ts +3 -2
  91. package/src/hub/reviewer-pool.ts +3 -0
  92. package/src/hub/routing.ts +23 -6
  93. package/src/hub/sdlc-merge.ts +299 -0
  94. package/src/hub/sdlc-metrics.ts +6 -4
  95. package/src/hub/sdlc-mirror.ts +129 -0
  96. package/src/hub/sdlc.ts +58 -12
  97. package/src/hub/settings.ts +11 -5
  98. package/src/hub/snapshots.ts +60 -0
  99. package/src/hub/task-repository.ts +31 -5
  100. package/src/hub/task-shell-command.ts +85 -0
  101. package/src/hub/task-shell-default.ts +78 -0
  102. package/src/hub/task-shell-proof.ts +171 -0
  103. package/src/hub/task-shell-sandbox.ts +61 -0
  104. package/src/hub/tasks.ts +378 -27
  105. package/src/local/deny.ts +91 -11
  106. package/src/local/sandbox.ts +67 -28
  107. package/src/local/shell-environment.ts +10 -0
  108. package/src/local/tools.ts +17 -4
  109. package/src/ui/index.html +3 -1
@@ -0,0 +1,171 @@
1
+ import { createHash } from "node:crypto";
2
+ import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { guardPath } from "../local/tools.ts";
6
+ import { hostExecutable } from "./host-executable.ts";
7
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
8
+ import { grantablePath } from "./permission-mode.ts";
9
+ import { realPath } from "./project.ts";
10
+ import { taskShellBindingValid, taskShellCommand, type TaskShellBinding, type TaskShellCommand } from "./task-shell-command.ts";
11
+
12
+ const within = (root: string, path: string) => { const rel = relative(root, path); return !rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel); };
13
+ const stamp = (path: string) => { const s = lstatSync(path, { throwIfNoEntry: false }); return s ? [path, s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs] : [path, "missing"]; };
14
+
15
+ /** One bounded synchronous metadata read. No network, ambient repository selectors or repository helper execution.
16
+ * ponytail: each reader's synchronous Git burst can block the hub for up to 1.5 seconds; move to asynchronous reads with one shared proof budget. */
17
+ function gitReader(root: string, env: Record<string, string>, current: () => boolean, projectRoot = root) {
18
+ if (!env.PATH || env.PATH.length > 16384 || env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) throw new Error("unavailable");
19
+ const executable = hostExecutable("git", root, env.PATH), deadline = performance.now() + 1500;
20
+ if (within(realPath(projectRoot), executable)) throw new Error("unavailable");
21
+ return (args: string[], absent = false): string => {
22
+ const left = Math.floor(deadline - performance.now());
23
+ if (!current() || left <= 0) throw new Error("unavailable");
24
+ const result = spawnSync(executable, hubGitArgv(["-C", root, ...args]).slice(1), {
25
+ cwd: root, env: hubGitEnv(env, { repositoryIsolation: true }), encoding: "utf8", timeout: left, maxBuffer: 64 * 1024,
26
+ });
27
+ if (!current() || result.error || result.status !== 0 && !(absent && result.status === 1)) throw new Error("unavailable");
28
+ return result.stdout;
29
+ };
30
+ }
31
+
32
+ export { readTaskShellDefaultBranch } from "./task-shell-default.ts";
33
+
34
+ /** Attached HEAD is live evidence; the default branch must be pinned by the host before task execution. */
35
+ export function readTaskShellBinding(root: string, env: Record<string, string>, current: () => boolean, projectRoot = root, defaultBranch?: string): TaskShellBinding | undefined {
36
+ try {
37
+ if (!defaultBranch) return;
38
+ const git = gitReader(realPath(root), env, current, projectRoot);
39
+ if (realPath(git(["rev-parse", "--show-toplevel"]).replace(/\n$/, "")) !== realPath(root)) return;
40
+ const branch = git(["symbolic-ref", "--quiet", "--short", "HEAD"]).replace(/\n$/, "");
41
+ const binding = { branch, defaultBranch };
42
+ return taskShellBindingValid(binding) && current() ? binding : undefined;
43
+ } catch { return undefined; }
44
+ }
45
+
46
+ export interface TaskShellProofOptions {
47
+ root: string; projectRoot: string; deny: string[]; readOnly?: string[]; protectInstructions: boolean;
48
+ binding: TaskShellBinding; env: Record<string, string>; repository?: string;
49
+ /** Host privacy screen for the captured PR body. Missing or unknown screening never grants a body-file upload. */
50
+ publicText?: (text: string) => boolean;
51
+ }
52
+ export interface TaskShellProof {
53
+ fingerprint: string; program: TaskShellCommand["program"]; executable: string; argv: readonly string[];
54
+ env: Readonly<Record<string, string>>; requiresSandbox: boolean;
55
+ }
56
+
57
+ /** Capture only a bounded regular file; the privileged CLI receives these screened bytes, never a path it can reopen later. */
58
+ function bodyFileText(file: string): string {
59
+ const initial = lstatSync(file);
60
+ if (!initial.isFile() || initial.size > 12000) throw new Error("unavailable");
61
+ const before = stamp(file), fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
62
+ try {
63
+ const stat = fstatSync(fd);
64
+ if (!stat.isFile() || stat.size > 12000 || JSON.stringify(before) !== JSON.stringify([file, stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs])) throw new Error("unavailable");
65
+ const bytes = Buffer.alloc(12001), count = readSync(fd, bytes, 0, bytes.length, 0), after = fstatSync(fd);
66
+ if (count !== stat.size || JSON.stringify(stamp(file)) !== JSON.stringify(before)
67
+ || [after.dev, after.ino, after.mode, after.size, after.mtimeMs, after.ctimeMs].some((value, index) => value !== [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs][index])) throw new Error("unavailable");
68
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, count));
69
+ if (!text.trim() || text.length > 3000 || text.includes("\0")) throw new Error("unavailable");
70
+ return text;
71
+ } finally { closeSync(fd); }
72
+ }
73
+
74
+ /** Reuse the core's command-line hardening as environment config for Git children spawned internally by gh. */
75
+ function gitChildEnvironment(env: Record<string, string>): Record<string, string> {
76
+ const result = Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
77
+ const argv = hubGitArgv([]).slice(1), configs: string[] = [];
78
+ for (let index = 0; index < argv.length; index++) {
79
+ if (argv[index] === "--no-pager") continue;
80
+ if (argv[index] !== "-c" || !argv[index + 1]?.includes("=")) throw new Error("unavailable");
81
+ configs.push(argv[++index]!);
82
+ }
83
+ result.GIT_CONFIG_COUNT = String(configs.length);
84
+ configs.forEach((config, index) => {
85
+ const split = config.indexOf("=");
86
+ result[`GIT_CONFIG_KEY_${index}`] = config.slice(0, split);
87
+ result[`GIT_CONFIG_VALUE_${index}`] = config.slice(split + 1);
88
+ });
89
+ return result;
90
+ }
91
+
92
+ /** Host task-command proof only; read-only commands continue to use the existing #816 proof. */
93
+ export function proveTaskShell(command: unknown, options: TaskShellProofOptions, current: () => boolean): TaskShellProof | undefined {
94
+ try {
95
+ if (!current() || options.readOnly?.length) return;
96
+ const form = taskShellCommand(command, options.binding);
97
+ if (!form) return;
98
+ const root = realPath(options.root), project = realPath(options.projectRoot), env = options.env;
99
+ if (!env.PATH || env.PATH.length > 16384 || env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) return;
100
+ const binding = readTaskShellBinding(root, env, current, project, options.binding.defaultBranch);
101
+ if (!binding || binding.branch !== options.binding.branch || binding.defaultBranch !== options.binding.defaultBranch) return;
102
+ const paths = new Set<string>();
103
+ const path = (value: string, writing = false) => {
104
+ const target = guardPath({ cwd: root, deny: options.deny, readOnly: options.readOnly }, value, "read");
105
+ if (writing && !grantablePath(root, target, value, options.protectInstructions)) throw new Error("unavailable");
106
+ paths.add(resolve(root, value)); paths.add(target); return target;
107
+ };
108
+ for (const value of form.paths) path(value, form.kind === "add");
109
+ const executable = form.program === "scripts/check.sh" ? path("scripts/check.sh") : hostExecutable(form.program, root, env.PATH);
110
+ if (form.program !== "scripts/check.sh" && within(project, executable)) return;
111
+ accessSync(executable, constants.X_OK);
112
+ if (!lstatSync(executable).isFile()) return;
113
+ paths.add(executable);
114
+ let argv = [...form.argv.slice(1)], repository: string | undefined;
115
+ let effectiveEnv: Record<string, string> = { ...env };
116
+ let bodyFileIndex = -1;
117
+ if (form.kind === "pr-create") for (let index = 3; index < form.argv.length; index += 2) {
118
+ if (form.argv[index] === "--body-file") bodyFileIndex = index - 1; // Effective argv excludes the program.
119
+ }
120
+ if (bodyFileIndex >= 0) {
121
+ if (!options.publicText) return;
122
+ const body = bodyFileText(path(argv[bodyFileIndex + 1]!));
123
+ if (options.publicText(body) !== true) return;
124
+ argv.splice(bodyFileIndex, 2, "--body", body);
125
+ }
126
+ if (form.program === "git" || form.program === "gh") {
127
+ const git = gitReader(root, env, current, project);
128
+ // Includes can change config between proof and execution; partial clones can trigger implicit helper/network activity.
129
+ if (git(["config", "--get-regexp", "^(include\\.path|includeif\\..*\\.path|extensions\\.partialclone|remote\\..*\\.promisor)$"], true).trim()) return;
130
+ // Only add/commit can run attribute-selected clean/smudge or content-diff drivers. Push/gh must keep working with a global LFS install.
131
+ if ((form.kind === "add" || form.kind === "commit") && git(["config", "--get-regexp", "^(filter\\..*\\.(clean|smudge|process)|diff\\..*\\.(command|textconv))$"], true).trim()) return;
132
+ if (form.kind === "add") {
133
+ const selected = git(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...form.paths]).split("\0").filter(Boolean);
134
+ if (selected.length > 256) return;
135
+ for (const value of selected) path(value, true);
136
+ }
137
+ if (form.kind === "commit") {
138
+ const staged = git(["diff", "--no-ext-diff", "--no-textconv", "--cached", "--name-only", "-z"]).split("\0").filter(Boolean);
139
+ if (staged.length > 256) return;
140
+ for (const value of staged) path(value, true);
141
+ }
142
+ if (form.kind === "push" || form.program === "gh") {
143
+ const expected = options.repository?.toLowerCase();
144
+ if (!expected || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(expected)) return;
145
+ const urls = [...git(["remote", "get-url", "--push", "--all", "origin"]).trim().split("\n"), ...git(["remote", "get-url", "--all", "origin"]).trim().split("\n")];
146
+ if (!urls.length || urls.length > 16 || urls.some(url => {
147
+ const match = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(url);
148
+ return match?.[1]?.toLowerCase() !== expected;
149
+ })) return;
150
+ // No remote-selected helper or implicit extra push effect is part of the closed command shape.
151
+ if (git(["config", "--get-regexp", "^(core\\.(sshcommand|gitproxy)|remote\\.origin\\.(vcs|proxy|receivepack|push|mirror)|push\\.pushoption)$"], true).trim()) return;
152
+ repository = expected;
153
+ }
154
+ if (form.kind === "push") argv[argv.length - 1] = `refs/heads/${binding.branch}:refs/heads/${binding.branch}`;
155
+ if (form.program === "git") {
156
+ effectiveEnv = Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
157
+ argv = hubGitArgv([
158
+ "-c", "commit.gpgSign=false", "-c", "push.gpgSign=false", "-c", "gc.auto=0", "-c", "maintenance.auto=false",
159
+ ...(form.kind === "push" ? ["-c", "push.followTags=false", "-c", "remote.origin.mirror=false", "-c", "push.recurseSubmodules=no"] : []), ...argv,
160
+ ]).slice(1);
161
+ } else {
162
+ effectiveEnv = gitChildEnvironment(env);
163
+ argv.push("--repo", `https://github.com/${repository}`);
164
+ }
165
+ }
166
+ const requiresSandbox = form.kind === "check" || form.program === "bun";
167
+ const rootStat = lstatSync(root);
168
+ const fingerprint = createHash("sha256").update(JSON.stringify([root, rootStat.dev, rootStat.ino, binding, effectiveEnv, executable, argv, repository, requiresSandbox, [...paths].map(stamp)])).digest("hex");
169
+ return current() ? { fingerprint, program: form.program, executable, argv: Object.freeze(argv), env: Object.freeze(effectiveEnv), requiresSandbox } : undefined;
170
+ } catch { return undefined; }
171
+ }
@@ -0,0 +1,61 @@
1
+ import { accessSync, constants, lstatSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
+ import { sbplString } from "../local/deny.ts";
5
+ import { hostExecutable } from "./host-executable.ts";
6
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
7
+ import { realPath } from "./project.ts";
8
+ import { taskShellBindingValid } from "./task-shell-command.ts";
9
+
10
+ export interface TaskShellSandboxOptions { projectRoot: string; env: Record<string, string> }
11
+ const inside = (root: string, file: string) => { const rel = relative(root, file); return !rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel); };
12
+ const literal = (file: string) => `(literal ${sbplString(file)})`;
13
+ const subpath = (file: string) => `(subpath ${sbplString(file)})`;
14
+
15
+ /** Deny-only refinement of the worker profile, never an execution grant or a replacement sandbox.
16
+ * All Git metadata is immutable here, including objects: object content is later trusted by privileged Git operations.
17
+ * Unknown layouts (including a non-linked checkout) ask instead of guessing metadata boundaries.
18
+ * ponytail: synchronous Git evidence shares a 1.5 second burst; an async host proof is the upgrade path. */
19
+ export function taskShellSandboxRestrictions(root: string, branch: string, options: TaskShellSandboxOptions): string | undefined {
20
+ try {
21
+ if (!taskShellBindingValid({ branch, defaultBranch: "main" }) || !options.env.PATH || options.env.PATH.length > 16384
22
+ || options.env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) return;
23
+ root = realPath(root);
24
+ const project = realPath(options.projectRoot), executable = hostExecutable("git", root, options.env.PATH);
25
+ if (inside(project, executable) || !lstatSync(executable).isFile()) return;
26
+ accessSync(executable, constants.X_OK);
27
+ const deadline = performance.now() + 1500;
28
+ const git = (...args: string[]) => {
29
+ const timeout = Math.floor(deadline - performance.now()); if (timeout <= 0) throw new Error("unavailable");
30
+ const result = spawnSync(executable, hubGitArgv(["-C", root, ...args]).slice(1), {
31
+ cwd: root, env: hubGitEnv(options.env, { repositoryIsolation: true }), encoding: "utf8", timeout, maxBuffer: 16384,
32
+ });
33
+ if (result.error || result.status !== 0 || !/^[^\r\n]+\n$/.test(result.stdout)) throw new Error("unavailable");
34
+ return result.stdout.slice(0, -1);
35
+ };
36
+ if (git("symbolic-ref", "--quiet", "--short", "HEAD") !== branch || realPath(git("rev-parse", "--show-toplevel")) !== root) return;
37
+ const gitDir = realPath(git("rev-parse", "--absolute-git-dir"));
38
+ const commonDir = realPath(resolve(root, git("rev-parse", "--git-common-dir")));
39
+ // Only a proven linked-worktree layout is supported; this refinement grants no metadata writes, even to the current worktree.
40
+ const worktreePart = relative(commonDir, gitDir).split("/");
41
+ if (worktreePart.length !== 2 || worktreePart[0] !== "worktrees" || !worktreePart[1] || gitDir === commonDir) return;
42
+ const entries = new Set<string>();
43
+ const directory = (file: string) => {
44
+ if (!lstatSync(file).isDirectory() || realPath(file) !== file || Buffer.byteLength(file) > 900 || /[\p{Cc}\p{Cf}]/u.test(file)) throw new Error("unavailable");
45
+ entries.add(file);
46
+ };
47
+ for (const metadata of [commonDir, gitDir]) directory(metadata);
48
+ for (let parent = dirname(gitDir); inside(commonDir, parent); parent = dirname(parent)) {
49
+ directory(parent); if (parent === commonDir) break;
50
+ }
51
+ // Protect metadata ancestors too when the metadata sits beneath the writable execution root.
52
+ for (const metadata of [commonDir, gitDir]) for (let parent = dirname(metadata); inside(root, parent); parent = dirname(parent)) {
53
+ directory(parent); if (parent === root) break;
54
+ }
55
+ return [
56
+ `(deny file-write* ${subpath(commonDir)} ${subpath(gitDir)})`,
57
+ // Exact metadata ancestor entries cannot be renamed or replaced either.
58
+ `(deny file-write* ${[...entries].sort().map(literal).join(" ")})`,
59
+ ].join("\n");
60
+ } catch { return undefined; }
61
+ }