@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
@@ -37,7 +37,7 @@ function runAgyTui(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise
37
37
  /** #607: agy 1.4.x attaches the plugin MCP server at the first prompt, so the hub tools are listed from the second turn. */
38
38
  export const AGY_LAUNCH_NOTICE = "Antigravity: pull-only TUI; native permissions unmanaged, no hub sandbox; hub tools appear from the second turn (send any first prompt, then ask for hub work)";
39
39
 
40
- /** agy auto-updates, so the 1.4.x line launches; only 1.4.2 has a T0 receipt and other patches warn (#589). */
40
+ /** agy auto-updates, so the 1.4.x line launches; only 1.4.2 and 1.4.7 have a T0 receipt and other patches warn (#589). */
41
41
  export function agyVersionGate(stdout: string): { ok: true; message?: string } | { ok: false; message: string } {
42
42
  // #691: the verified versions, the compatible line and the person's trust live in compat.ts.
43
43
  const verdict = assessVersion("agy", COMPAT.agy.parse(stdout));
@@ -400,9 +400,13 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
400
400
  || !args.input || typeof args.input !== "object" || Array.isArray(args.input)
401
401
  || typeof args.tool_use_id !== "string" || !args.tool_use_id.length || args.tool_use_id.length > 256) return deny("invalid native permission request");
402
402
  try {
403
- // Keep original arguments only in this native request closure; the host returns a decision, not replacements.
404
- const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input }, 3_605_000);
405
- return res.ok === true && res.behavior === "allow" ? text(JSON.stringify({ behavior: "allow", updatedInput: args?.input })) : deny(typeof res.message === "string" ? res.message : "permission denied");
403
+ // Only this fixed client capability permits host wrapper grants; older clients keep person approval.
404
+ const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input, wrapperProtocol: 1 }, 3_605_000);
405
+ if (res.ok !== true || res.behavior !== "allow") return deny(typeof res.message === "string" ? res.message : "permission denied");
406
+ if (res.wrapperProtocol !== undefined && res.wrapperProtocol !== 1) return deny("unsupported host execution response");
407
+ const updated = res.wrapperProtocol === 1 ? res.updatedInput : undefined;
408
+ if (res.wrapperProtocol === 1 && (args.tool_name !== "Bash" || !updated || typeof updated !== "object" || Array.isArray(updated) || typeof updated.command !== "string")) return deny("invalid host execution response");
409
+ return text(JSON.stringify({ behavior: "allow", updatedInput: updated ?? args?.input }));
406
410
  } catch { return deny("hub permission unavailable"); }
407
411
  }
408
412
  if (name === "hub_delivery_done" && channel) {
@@ -23,6 +23,8 @@ export interface ClaudeImplementerOptions {
23
23
  factsHook: string;
24
24
  systemPrompt: string;
25
25
  watchdogMs: number;
26
+ /** Declared `model` of this instance (#844): one validated word passed as `--model`; unset leaves the native default and the argv unchanged. */
27
+ model?: string;
26
28
  mode?: "ask" | "ask-when-needed";
27
29
  /** Exact host-reported transcript evidence only; unknown/read errors retain the recorded UUID. */
28
30
  transcriptPersisted?(task: number, sessionId: string): boolean | "unknown";
@@ -38,10 +40,10 @@ export interface ClaudeImplementerOptions {
38
40
  prove?(): Promise<() => boolean>;
39
41
  }
40
42
  export interface ImplementerTurn { sessionId: string; resume: boolean; persistent: boolean }
41
- export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string): string[] {
43
+ export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string, model?: string): string[] {
42
44
  return ["-p", "--output-format", "stream-json", "--verbose", turn.resume ? "--resume" : "--session-id", turn.sessionId,
43
45
  ...(!turn.persistent ? ["--no-session-persistence"] : []), "--strict-mcp-config", "--mcp-config", mcp, "--setting-sources", "", "--settings", settings,
44
- "--permission-mode", "default", "--permission-prompts", "host", "--permission-prompt-tool", "mcp__agent-hub__hub_permission_request",
46
+ ...(model ? ["--model", model] : []), "--permission-mode", "default", "--permission-prompts", "host", "--permission-prompt-tool", "mcp__agent-hub__hub_permission_request",
45
47
  "--allowedTools", ...IMPLEMENTER_HUB_TOOL_NAMES.map(name => `mcp__agent-hub__${name}`), "--append-system-prompt", prompt];
46
48
  }
47
49
 
@@ -184,7 +186,7 @@ export class ClaudeImplementerPeer extends BasePeer {
184
186
  AGENTHUB_INSTANCE_ID: this.options.instanceId, AGENTHUB_LAUNCH_ID: launchId, AGENTHUB_HOOK_PURPOSE: "permission", AGENTHUB_PERMISSION_TOOL: "1" };
185
187
  const mcp = JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", this.options.server], env } } });
186
188
  let result = false, failed = false;
187
- const child = new OwnedPrintChild({ peer: this.id, command: this.command!, args: claudeImplementerArgs(turn, mcp, settings, this.options.systemPrompt), cwd: this.options.cwd,
189
+ const child = new OwnedPrintChild({ peer: this.id, command: this.command!, args: claudeImplementerArgs(turn, mcp, settings, this.options.systemPrompt, this.options.model), cwd: this.options.cwd,
188
190
  input: group.envs.map(frame).join("\n\n"), env, current, stop: this.options.stopChild,
189
191
  onSpawn: () => {
190
192
  try {
@@ -21,48 +21,62 @@ export interface CompatPolicy {
21
21
  parse: (stdout: string) => string | undefined;
22
22
  /** What runtime checks still decide after trust (isolation) or what stays unverified (contract). */
23
23
  trustNote: string;
24
+ /** The last receipt in docs/verification whose model-free procedure a new version repeats (#843). */
25
+ receipt: string;
24
26
  /** The config key that selects this kind's executable, when one exists. */
25
- config?: "codex_bin" | "jcode_cmd" | "qwen_cmd";
27
+ config?: "codex_bin" | "jcode_cmd" | "qwen_cmd" | "pi.cmd";
26
28
  }
27
29
 
28
30
  const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
29
31
  export const COMPAT = {
30
- // Receipt: docs/verification/2026-10-23-headless-claude-permissions.md
31
- "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309"], gate: "contract", probe: ["claude", "--version"],
32
+ // Receipt: docs/verification/2026-10-26-pi-140-compat.md
33
+ pi: { label: "Pi", verified: ["1.4.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
34
+ parse: out => plain(out.trim()),
35
+ trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
36
+ receipt: "docs/verification/2026-10-26-pi-140-compat.md" },
37
+ // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; host-wrapper receipt below repeats with them.
38
+ "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310"], gate: "contract", probe: ["claude", "--version"],
32
39
  parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
33
- trustNote: "The native prompt-tool allow, deny, MCP error/disconnect and hook-failure fallthrough have an isolated receipt. No dontAsk/bypass fallback exists; another version's permission behavior is unverified." },
40
+ trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
41
+ receipt: "docs/verification/2026-10-26-headless-claude-shell-context.md" },
34
42
  // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md
35
43
  "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
36
44
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
37
- trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md)." },
38
- // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md
39
- "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1"], gate: "isolation", probe: ["kimi", "--version"],
45
+ trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md).",
46
+ receipt: "docs/verification/2026-10-24-codex-0164-reviewer-isolation.md" },
47
+ // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md
48
+ "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2"], gate: "isolation", probe: ["kimi", "--version"],
40
49
  parse: out => plain(out.trim()),
41
- trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven." },
42
- jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
50
+ trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven.",
51
+ receipt: "docs/verification/2026-10-26-kimi-232-reviewer-compat.md" },
52
+ jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0", "0.99.1"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
43
53
  parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
44
- trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and steering on 0.99.0; an untested version may change them." },
45
- agy: { label: "agy", verified: ["1.4.2"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
54
+ trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
55
+ receipt: "docs/verification/2026-10-26-jcode-0.99.1.md" },
56
+ agy: { label: "agy", verified: ["1.4.2", "1.4.7"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
46
57
  parse: out => plain(out.trim()),
47
- trustNote: "Only 1.4.2 is T0-verified; the plugin MCP and hook contract of another version is unverified." },
48
- qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
58
+ trustNote: "Only 1.4.2 and 1.4.7 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
+ receipt: "docs/verification/2026-10-26-agy-1.4.7.md" },
60
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
49
61
  parse: out => plain(out.trim()),
50
- trustNote: "Native ACP identity and mode ids have dated receipts; 0.26.0 permission callbacks were checked without inference. Another version may change those contracts." },
62
+ trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.1; its two ACP fixes were judged not to change the hub contract. Another version may change those contracts.",
63
+ receipt: "docs/verification/2026-10-26-qwen-0261-acp.md" },
51
64
  // Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
52
65
  // the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
53
66
  "qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
54
67
  parse: out => plain(out.trim()),
55
- trustNote: "The dual-output file contract (submit and confirmation_response input, user/assistant/stream_event/control events, session_start identity) was verified on 0.26.1 only. A hub deny ends the turn without any Qwen event, and no external stop of a running turn exists; another version may change either." },
68
+ trustNote: "The dual-output file contract (submit and confirmation_response input, user/assistant/stream_event/control events, session_start identity) was verified on 0.26.1 only. A hub deny ends the turn without any Qwen event, and no external stop of a running turn exists; another version may change either.",
69
+ receipt: "docs/verification/2026-10-25-qwen-dual-output-t0.md" },
56
70
  } as const satisfies Record<string, CompatPolicy>;
57
71
  export type CompatId = keyof typeof COMPAT;
58
- type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[] };
72
+ type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[]; pi?: { cmd: string[] } };
59
73
  /** The configured executables the native gates use; the rest probe their PATH default. */
60
74
  export function configuredCommands(config: CommandConfig): Partial<Record<CompatId, readonly string[]>> {
61
75
  const out: Partial<Record<CompatId, readonly string[]>> = {};
62
76
  for (const [id, policy] of Object.entries(COMPAT) as [CompatId, CompatPolicy][]) {
63
77
  if (!policy.config) continue;
64
- const value = config[policy.config];
65
- out[id] = typeof value === "string" ? [value] : value;
78
+ const value = policy.config === "pi.cmd" ? config.pi?.cmd : config[policy.config];
79
+ if (value !== undefined) out[id] = typeof value === "string" ? [value] : value;
66
80
  }
67
81
  return out;
68
82
  }
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ if (process.argv.includes("--version")) { console.log("1.4.0"); process.exit(0); }
4
5
  const option = (flag: string) => process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : undefined;
5
6
  const empty = process.argv.includes("--empty-session");
6
7
  const dir = option("--session-dir")!;
@@ -61,7 +61,7 @@ const dualForms = Object.freeze(["tui", "headless"] as const);
61
61
  const headlessForms = Object.freeze(["headless"] as const);
62
62
  const hubGrants = {
63
63
  ask: "every write, edit and shell command asks",
64
- "ask-when-needed": "write and edit inside the project run without asking; shell and git writes still ask",
64
+ "ask-when-needed": "write, edit and proven bounded read-only shell commands inside the project run without asking; other shell commands and git writes still ask",
65
65
  "never-ask": "every tool runs without asking, inside the hub sandbox and path guard",
66
66
  };
67
67
 
@@ -85,8 +85,8 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
85
85
  api: 1, kind: "claude", displayName: "Claude Code implementer", family: "claude", transport: "hub-native",
86
86
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
87
87
  start: { create: createClaudeImplementer, daemonPort: "claude-implementer", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
88
- identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"] },
89
- permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools inside the owned worktree may run automatically; shell and protected paths ask" } },
88
+ identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"], modelSelect: true },
89
+ permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools, proven read-only shell commands and bound task commands inside the owned worktree may run automatically; protected paths and other commands ask" } },
90
90
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: false, launchKind: "claude-implementer", crashPlan: () => ({ resume: {}, how: "Claude implementer resumes its recorded per-task sessions" }) },
91
91
  turnEnd: "adapter", budget: { account: "claude" },
92
92
  cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
@@ -34,8 +34,13 @@ export function createQwenDual(ctx: CreateContext): PeerAdapter {
34
34
  export function createPi(ctx: CreateContext): PeerAdapter {
35
35
  if (!("pi" in ctx.daemon)) throw new Error("Pi requires its Pi transport options");
36
36
  if (ctx.permission === "read-only") throw new Error("Pi has no verified read-only permission mapping");
37
- const { PiPeer } = require("../pi.ts") as typeof import("../pi.ts");
38
- return new PiPeer(ctx.instance, ctx.daemon.pi);
37
+ const { PiPeer, PiVersionRefusal, piVersionAsync } = require("../pi.ts") as typeof import("../pi.ts");
38
+ const options = ctx.daemon.pi;
39
+ return new PiPeer(ctx.instance, options, async () => {
40
+ const verdict = await piVersionAsync(options.cmd ?? ["pi"], options.cwd);
41
+ if (!verdict.ok) throw new PiVersionRefusal(verdict);
42
+ if (verdict.note) options.log?.(verdict.note);
43
+ });
39
44
  }
40
45
 
41
46
  export function createLocal(ctx: CreateContext): PeerAdapter {
@@ -19,9 +19,9 @@ function reviewerVariant(kind: string, displayName: string, reviewer: NonNullabl
19
19
  reviewer, cli: { command: kind, startWords: () => [] }, display,
20
20
  };
21
21
  }
22
- const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit" }, CODEX_DISPLAY);
22
+ const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit", scope: "code" }, CODEX_DISPLAY);
23
23
  export const CODEX_REVIEWER_VARIANT: AdapterDefinition = { ...codexVariant, start: { ...codexVariant.start, create: createCodexReviewer } };
24
- export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { backend: kimiReviewerBackend, defaultPool: true, declaration: "explicit" }, KIMI_DISPLAY);
24
+ export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { backend: kimiReviewerBackend, defaultPool: true, declaration: "explicit", scope: "code" }, KIMI_DISPLAY);
25
25
  /** Data-only declared placeholder for route explanations; no factory, backend or native argv. */
26
26
  export const AGY_REVIEWER_VARIANT: AdapterDefinition = {
27
27
  ...reviewerVariant("agy", "agy reviewer", { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" }, { palette: { fg: "#6558a6", bg: "#f1effa" } }),
@@ -36,7 +36,7 @@ export interface LocalOptions {
36
36
  turnId?: () => string | undefined;
37
37
  onApprovalStop?: (reason: string) => void;
38
38
  onTool?: (observation: ToolObservation, task?: string) => void;
39
- tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
39
+ tools: { deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
40
40
  capture?: Capture;
41
41
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
42
42
  taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
@@ -290,11 +290,12 @@ export class LocalPeer extends BasePeer {
290
290
  }
291
291
 
292
292
  private toolContext(envs: Envelope[], turnSignal: AbortSignal, pii: boolean, reply: EnvelopeOpts): ToolContext {
293
+ const executionGeneration=this.turn;
293
294
  return {
294
295
  cwd: this.opts.cwd,
295
296
  deny: this.opts.tools.deny,
296
297
  readOnly: this.opts.tools.readOnly,
297
- permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath, input) => {
298
+ permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath, input, policy) => {
298
299
  if (turnSignal.aborted) return "aborted";
299
300
  const generation = this.turn;
300
301
  let observed = false;
@@ -314,7 +315,7 @@ export class LocalPeer extends BasePeer {
314
315
  const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
315
316
  const onAbort = () => finish("aborted");
316
317
  turnSignal.addEventListener("abort", onAbort, { once: true });
317
- this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath, input).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
318
+ this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath, input, policy).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
318
319
  });
319
320
  if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
320
321
  // Legacy permit callbacks can represent a person's denial without provenance. A true
@@ -323,6 +324,7 @@ export class LocalPeer extends BasePeer {
323
324
  if (!observed && picked === "expired") observe({ source: "expired", answerEpoch: this.approvalAnswerEpoch ?? 0, eligibleExpiry: true });
324
325
  return picked;
325
326
  },
327
+ shellExecutionCheck: input=>turnSignal.aborted||executionGeneration!==this.turn?"aborted":this.opts.tools.shellExecutionCheck?.(input)??"valid",
326
328
  sandboxProfile: this.sandboxProfile,
327
329
  sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },
328
330
  signal: turnSignal,
@@ -2,10 +2,11 @@ import { buildPiLaunch } from "../pi/launch.ts";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync, mkdirSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
+ import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
6
6
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
7
7
  import { BasePeer } from "../hub/peers.ts";
8
- import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
8
+ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
+ import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
9
10
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
10
11
  import { PiSessionBookmarkMismatch, validatePiSession } from "../pi/session.ts";
11
12
  import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
@@ -56,6 +57,39 @@ export interface PiOptions {
56
57
  signature?: typeof processSignature;
57
58
  }
58
59
  export interface PiTuiLaunch { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }
60
+
61
+ /** Only the host's parsed version verdict may cross the generic startup-error boundary. */
62
+ export class PiVersionRefusal extends Error {
63
+ constructor(verdict: Extract<CompatVerdict, { ok: false }>) { super(verdict.reason); }
64
+ }
65
+
66
+ /** Bounded, non-blocking probe of the exact configured native command, including wrapper arguments. */
67
+ export function piVersionAsync(command: readonly string[], cwd: string, timeoutMs = 10_000): Promise<CompatVerdict> {
68
+ const [bin, ...prefix] = command;
69
+ if (!bin) return Promise.resolve(assessVersion("pi", undefined));
70
+ return new Promise(resolve => {
71
+ let proc: ChildProcess;
72
+ try { proc = spawn(bin, [...prefix, "--version"], { cwd, env: peerChildEnv("pi"), detached: true, stdio: ["ignore", "pipe", "ignore"] }); }
73
+ catch { resolve(assessVersion("pi", undefined)); return; }
74
+ let output = "", bytes = 0, done = false;
75
+ const finish = async (code: number | null) => {
76
+ if (done) return; done = true; clearTimeout(timer);
77
+ try { await stopOwnedProcess(proc, { group: true }); }
78
+ catch { resolve(assessVersion("pi", undefined)); return; }
79
+ resolve(assessVersion("pi", code === 0 && bytes <= 64 * 1024 ? COMPAT.pi.parse(output) : undefined));
80
+ };
81
+ const timer = setTimeout(() => { void finish(null); }, timeoutMs);
82
+ proc.once("spawn", () => trackGroup(proc));
83
+ proc.stdout!.on("data", (chunk: Buffer) => {
84
+ if (done) return;
85
+ bytes += chunk.length;
86
+ if (bytes > 64 * 1024) { void finish(null); return; }
87
+ output += chunk.toString("utf8");
88
+ });
89
+ proc.once("error", () => { void finish(null); });
90
+ proc.once("close", code => { void finish(code); });
91
+ });
92
+ }
59
93
  type RpcMessage = { type?: string; id?: string | number; command?: string; success?: boolean; data?: any; [key: string]: any };
60
94
  type VerifiedEmptyResume = { sessionId: string };
61
95
  type TerminalOwnerObservation = { pid: number; signature: string; sessionId: string; sessionFile: string; ownerToken: string; checkedAt: number };
@@ -132,7 +166,8 @@ export class PiPeer extends BasePeer {
132
166
  private tuiQueue: Record<string, unknown>[] = [];
133
167
  private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
134
168
 
135
- constructor(id: PeerId, private readonly opts: PiOptions) { super(id, opts.watchdogMs); }
169
+ /** The fixed production factory supplies preflight separately, preserving transport option identity. */
170
+ constructor(id: PeerId, private readonly opts: PiOptions, private readonly preflight?: () => Promise<void>) { super(id, opts.watchdogMs); }
136
171
  get tuiLaunch(): PiTuiLaunch | undefined { return this._tuiLaunch; }
137
172
  get pendingResume(): { sessionId?: string; sessionFile?: string } {
138
173
  if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
@@ -344,6 +379,7 @@ export class PiPeer extends BasePeer {
344
379
  }
345
380
 
346
381
  private async startImpl(): Promise<void> {
382
+ if (this.preflight) await this.preflight();
347
383
  this.terminalOwnerObservation = undefined;
348
384
  this.stopping = false;
349
385
  this.rosterDetached = false; this.rosterUnownedRun = false;
@@ -82,6 +82,17 @@ export function qwenDualNamesStateDir(input: unknown, stateDir: string): boolean
82
82
  return seen(input, 0);
83
83
  }
84
84
 
85
+ /**
86
+ * #517: Qwen tells the model that one of its background shells finished with a `user` event of its own, which is neither the hub's submit nor the person typing. Qwen may fold
87
+ * several finished shells into one event, each its own envelope, separated by one blank line (8 of the 48 in the live event files do). The shape is exact: the text is wholly
88
+ * one or more complete envelopes and nothing else, so a person's text that merely contains or resembles one is still a person's prompt. Nothing inside an envelope is read
89
+ * beyond its header lines, so nothing in it can steer the adapter.
90
+ */
91
+ const NOTIFICATION = String.raw`<task-notification>\n<task-id>bg_[0-9a-f]{4,32}<\/task-id>\n<kind>[a-z_-]{1,32}<\/kind>\n<status>[a-z_-]{1,32}<\/status>\n(?:(?!<task-notification>|<\/task-notification>)[\s\S])*<\/task-notification>`;
92
+ const QWEN_TASK_NOTIFICATIONS = new RegExp(`^${NOTIFICATION}(?:\\n\\n${NOTIFICATION})*$`);
93
+ const NOTIFICATION_CAP = 64 * 1024;
94
+ export function isQwenTaskNotification(text: string): boolean { return text.length <= NOTIFICATION_CAP && QWEN_TASK_NOTIFICATIONS.test(text); }
95
+
85
96
  /** `qwen --version` against the dual-output compat entry. Never throws: a refusal is a reason. */
86
97
  export async function qwenDualVersionAsync(cmd: readonly string[], cwd: string): Promise<{ ok: true; version: string; note?: string } | { ok: false; reason: string }> {
87
98
  const [bin, ...prefix] = cmd.filter(word => word !== "--acp");
@@ -416,6 +427,17 @@ export class QwenDualPeer extends BasePeer {
416
427
  if (!this.stopping && !this.detached && this.state === "offline") this.setState("idle");
417
428
  }
418
429
 
430
+ /**
431
+ * Judged on the raw event, not on `promptText`, which joins every text block and drops the rest: an event is Qwen's own notice only when its content is exactly one text block
432
+ * and that block is wholly the strict envelope. A text split across blocks, or a block with anything else beside it (an image, a second text), is a person's prompt.
433
+ */
434
+ private isNotificationEvent(event: Json): boolean {
435
+ const content = event.message?.content;
436
+ if (!Array.isArray(content) || content.length !== 1) return false;
437
+ const block = content[0];
438
+ return !!block && typeof block === "object" && block.type === "text" && typeof block.text === "string" && isQwenTaskNotification(block.text);
439
+ }
440
+
419
441
  private promptText(event: Json): string | undefined {
420
442
  const content = event.message?.content;
421
443
  if (typeof content === "string") return content;
@@ -425,6 +447,9 @@ export class QwenDualPeer extends BasePeer {
425
447
  }
426
448
 
427
449
  private onUser(event: Json): void {
450
+ // Qwen's own background-shell notice (#517) is part of whatever turn is running and never the person typing, so it ends no hub turn and holds no queue. It changes nothing else:
451
+ // an idle peer stays idle (Qwen may answer it or not, and a peer that waited for an answer that never comes would block its queue), and a running turn keeps its owner.
452
+ if (this.isNotificationEvent(event)) return;
428
453
  const text = this.promptText(event);
429
454
  if (text === undefined) return; // a tool result echoed back, not a prompt
430
455
  const turn = this.turn;
@@ -210,7 +210,9 @@ export interface AdapterDefinition {
210
210
  /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
211
211
  instances?: { mode: "external" | "headless"; roles?: readonly string[];
212
212
  /** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
213
- nativeHome?: true };
213
+ nativeHome?: true;
214
+ /** A declared headless instance of this definition may pass one validated `model` word to its native child (#844). Fixed builtins only. */
215
+ modelSelect?: true };
214
216
  attach?: {
215
217
  /** The authenticated external hello may claim this id; independent of MCP configuration shape. */
216
218
  externalHello?: boolean;
@@ -309,7 +311,9 @@ export interface AdapterDefinition {
309
311
  /** #525: fixed builtin variant for declared headless implementer instances. */
310
312
  headlessVariant?: AdapterDefinition;
311
313
  /** Fixed host backend; runtime isolation proof is required before bus admission. */
312
- reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit" };
314
+ reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit";
315
+ /** #852: what a reviewer of this kind reads. `code`: the code only, its sandbox cannot run tests or read CI. Unset means `full`. Fixed builtins only. */
316
+ scope?: "full" | "code" };
313
317
  }
314
318
 
315
319
  /** Explicit project declarations, passed by the bound host. No process-global instance registration. */
@@ -259,6 +259,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
259
259
  ["cli.entry", declared(definition.cli, "entry")],
260
260
  ["cli.preview", declared(definition.cli, "preview")],
261
261
  ["cli.usage", declared(definition.cli, "usage")],
262
+ ["instances.modelSelect", declared(definition.instances, "modelSelect")], // #844: a fixed-builtin assumption, one validated model word per declared headless instance
262
263
  ];
263
264
  const field = restricted.find(([, present]) => present)?.[0];
264
265
  if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
package/src/cli/agents.ts CHANGED
@@ -4,19 +4,81 @@ import { assessVersion, COMPAT, describe, isCompatId, readTrust, trustFile, writ
4
4
  /** #691: what `ahub agents` and `ahub doctor` show for each gated native CLI. */
5
5
  export interface AgentVersionRow { id: CompatId; executable?: string; verdict: CompatVerdict; policy: string }
6
6
 
7
- /** `commands` carries the configured argv (codex_bin, jcode_cmd, qwen_cmd); the probe words follow it. */
7
+ const PROBE_ENV = { JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" };
8
+
9
+ /** The one place that decides what is run for an id: the configured argv (including pi.cmd) plus the probe words. */
10
+ function probeTarget(id: CompatId, commands: Partial<Record<CompatId, readonly string[]>>): { executable: string; args: string[] } | undefined {
11
+ const [defaultBin, ...words] = COMPAT[id].probe;
12
+ const [bin = defaultBin, ...prefix] = commands[id] ?? [defaultBin];
13
+ const executable = Bun.which(bin) ?? undefined;
14
+ return executable ? { executable, args: [...prefix, ...words] } : undefined;
15
+ }
16
+
17
+ function probeRow(id: CompatId, executable: string | undefined, stdout: string | undefined, trusted: ReturnType<typeof readTrust>): AgentVersionRow {
18
+ if (!executable) return { id, verdict: { ok: false, state: "unknown", reason: `${COMPAT[id].label} not found on PATH` }, policy: describe(id) };
19
+ return { id, executable, verdict: assessVersion(id, stdout === undefined ? undefined : COMPAT[id].parse(stdout), trusted), policy: describe(id) };
20
+ }
21
+
22
+ /** `commands` carries the configured argv (including pi.cmd); the probe words follow it. */
8
23
  export function agentVersions(commands: Partial<Record<CompatId, readonly string[]>> = {}): AgentVersionRow[] {
9
24
  const trusted = readTrust();
10
25
  return (Object.keys(COMPAT) as CompatId[]).map(id => {
11
- const [defaultBin, ...words] = COMPAT[id].probe;
12
- const [bin = defaultBin, ...prefix] = commands[id] ?? [defaultBin];
13
- const executable = Bun.which(bin) ?? undefined;
14
- if (!executable) return { id, verdict: { ok: false, state: "unknown", reason: `${COMPAT[id].label} not found on PATH` }, policy: describe(id) };
15
- const result = spawnSync(executable, [...prefix, ...words], { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { ...process.env, JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" } });
16
- return { id, executable, verdict: assessVersion(id, result.status === 0 ? COMPAT[id].parse(result.stdout ?? "") : undefined, trusted), policy: describe(id) };
26
+ const target = probeTarget(id, commands);
27
+ if (!target) return probeRow(id, undefined, undefined, trusted);
28
+ const result = spawnSync(target.executable, target.args, { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { ...process.env, ...PROBE_ENV } });
29
+ return probeRow(id, target.executable, result.status === 0 ? result.stdout ?? "" : undefined, trusted);
17
30
  });
18
31
  }
19
32
 
33
+ const PROBE_OUTPUT_CAP = 64 * 1024;
34
+ /** After the probe process ends, how long a descendant that kept the pipe open may still be read before the reader is cancelled. */
35
+ const PROBE_EXIT_GRACE_MS = 250;
36
+
37
+ /**
38
+ * One bounded probe run. Output is read as a stream and counted as it arrives: past the cap the probe is killed and the
39
+ * reader cancelled, so a chatty or endless CLI never accumulates in memory. A deadline kills the probe and cancels the
40
+ * reader, and a descendant that inherited stdout cannot hold the reader past the grace after the probe's own exit.
41
+ * ponytail: only the probe process is killed, not its descendants; a stray one ends on its own or when its pipe closes.
42
+ * The upgrade is a process group via child_process (as `jcodeVersionAsync` does).
43
+ */
44
+ async function runProbe(executable: string, args: readonly string[], timeoutMs: number, graceMs = PROBE_EXIT_GRACE_MS): Promise<string | undefined> {
45
+ const child = Bun.spawn([executable, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore", env: { ...process.env, ...PROBE_ENV }, timeout: timeoutMs, killSignal: "SIGKILL" });
46
+ const reader = child.stdout.getReader();
47
+ const chunks: Uint8Array[] = [];
48
+ let total = 0, overflow = false, cancelled = false;
49
+ const release = () => { if (cancelled) return; cancelled = true; void reader.cancel().catch(() => undefined); };
50
+ const pump = (async () => {
51
+ try {
52
+ for (;;) {
53
+ const { done, value } = await reader.read();
54
+ if (done) return;
55
+ total += value.byteLength;
56
+ if (total > PROBE_OUTPUT_CAP) { overflow = true; child.kill("SIGKILL"); release(); return; }
57
+ chunks.push(value);
58
+ }
59
+ } catch { /* a cancelled or failed read ends the probe with what was counted */ }
60
+ })();
61
+ const deadline = setTimeout(() => { child.kill("SIGKILL"); release(); }, timeoutMs + graceMs);
62
+ let grace: ReturnType<typeof setTimeout> | undefined;
63
+ try {
64
+ const code = await child.exited;
65
+ grace = setTimeout(release, graceMs);
66
+ await pump;
67
+ return code === 0 && !overflow ? Buffer.concat(chunks).toString("utf8") : undefined;
68
+ } finally { clearTimeout(deadline); clearTimeout(grace); release(); }
69
+ }
70
+
71
+ /** #843 AC3: the same probe for the daemon, which must not block its event loop for up to ten seconds an id. */
72
+ export async function agentVersionsAsync(commands: Partial<Record<CompatId, readonly string[]>> = {}, timeoutMs = 10_000, graceMs = PROBE_EXIT_GRACE_MS): Promise<AgentVersionRow[]> {
73
+ const trusted = readTrust();
74
+ return Promise.all((Object.keys(COMPAT) as CompatId[]).map(async id => {
75
+ const target = probeTarget(id, commands);
76
+ if (!target) return probeRow(id, undefined, undefined, trusted);
77
+ try { return probeRow(id, target.executable, await runProbe(target.executable, target.args, timeoutMs, graceMs), trusted); }
78
+ catch { return probeRow(id, target.executable, undefined, trusted); }
79
+ }));
80
+ }
81
+
20
82
  export function runAgents(args: string[], commands: Partial<Record<CompatId, readonly string[]>>, fail: (message: string) => never): void {
21
83
  const [sub, id, version, ...rest] = args.filter(arg => arg !== "--json");
22
84
  if (sub === "trust") {
@@ -0,0 +1,54 @@
1
+ import { request } from "node:http";
2
+ import { isAbsolute, basename } from "node:path";
3
+
4
+ export const HOST_SHELL_PROTOCOL = Object.freeze({ chunkBytes: 16 * 1024, frameBytes: 24 * 1024, failure: 125, maximumMs: 3_600_000, refusal: "host shell execution refused" });
5
+
6
+ async function output(stream: NodeJS.WriteStream, data: Buffer): Promise<void> {
7
+ await new Promise<void>((resolve, reject) => stream.write(data, error => error ? reject(error) : resolve()));
8
+ }
9
+
10
+ async function main(): Promise<number> {
11
+ const [socket, ticket, ...extra] = process.argv.slice(2);
12
+ if (!socket || !isAbsolute(socket) || socket.length > 4096 || !ticket || !/^[a-f0-9]{64}$/.test(ticket) || extra.length) throw new Error();
13
+ const body = JSON.stringify({ ticket });
14
+ const call = request({ socketPath: socket, path: "/execute", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body), connection: "close" } });
15
+ const connection = new Promise<import("node:http").IncomingMessage>((resolve, reject) => { call.once("response", resolve); call.once("error", reject); });
16
+ const opening = setTimeout(() => call.destroy(new Error()), 5000);
17
+ const total = setTimeout(() => call.destroy(new Error()), HOST_SHELL_PROTOCOL.maximumMs + 10_000);
18
+ let response: import("node:http").IncomingMessage | undefined;
19
+ try {
20
+ call.end(body); response = await connection; clearTimeout(opening);
21
+ if (response.statusCode !== 200 || response.headers["content-type"] !== "application/x-ndjson") throw new Error();
22
+ let pending = "", status: number | undefined;
23
+ for await (const raw of response) {
24
+ const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
25
+ if (chunk.length > 1024 * 1024) throw new Error();
26
+ pending += chunk.toString("utf8");
27
+ for (let end; (end = pending.indexOf("\n")) >= 0;) {
28
+ if (end > HOST_SHELL_PROTOCOL.frameBytes) throw new Error();
29
+ const line = pending.slice(0, end); pending = pending.slice(end + 1);
30
+ if (!line || status !== undefined) throw new Error();
31
+ const frame = JSON.parse(line);
32
+ if (!frame || typeof frame !== "object" || Array.isArray(frame)) throw new Error();
33
+ const keys = Object.keys(frame).sort().join(",");
34
+ if (frame.type === "exit" && keys === "code,type" && Number.isSafeInteger(frame.code) && frame.code >= 0 && frame.code <= 255) { status = frame.code; continue; }
35
+ if (!["stdout", "stderr"].includes(frame.type) || keys !== "data,type" || typeof frame.data !== "string" || frame.data.length > 4 * Math.ceil(HOST_SHELL_PROTOCOL.chunkBytes / 3)) throw new Error();
36
+ const bytes = Buffer.from(frame.data, "base64");
37
+ if (bytes.length > HOST_SHELL_PROTOCOL.chunkBytes || bytes.toString("base64") !== frame.data) throw new Error();
38
+ await output(frame.type === "stdout" ? process.stdout : process.stderr, bytes);
39
+ }
40
+ if (pending.length > HOST_SHELL_PROTOCOL.frameBytes) throw new Error();
41
+ }
42
+ if (pending || status === undefined) throw new Error();
43
+ return status;
44
+ } finally { clearTimeout(opening); clearTimeout(total); response?.destroy(); call.destroy(); }
45
+ }
46
+
47
+ // The filename fence also keeps this inert if a host imports the protocol constants through a bundle.
48
+ if (import.meta.main && basename(process.argv[1] ?? "") === "claude-shell-wrapper.ts") {
49
+ process.stdout.on("error", () => {}); process.stderr.on("error", () => {});
50
+ void main().then(code => { process.exitCode = code; }, async () => {
51
+ try { await output(process.stderr, Buffer.from(`${HOST_SHELL_PROTOCOL.refusal}\n`)); } catch { /* broken caller pipe */ }
52
+ process.exitCode = HOST_SHELL_PROTOCOL.failure;
53
+ });
54
+ }
@@ -47,7 +47,8 @@ export interface ConsoleState {
47
47
  autoSelected?: boolean;
48
48
  input: string; editing: boolean; history: string[]; historyIndex: number;
49
49
  approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
50
- taskWip?: Record<string, { wip: number; wipLimit: number }>;
50
+ /** #784 AC3: the split of `wip` (active, review-wait, reserved); absent from an older hub. */
51
+ taskWip?: Record<string, { wip: number; wipLimit: number; active?: number; reviewWait?: number; reserved?: number }>;
51
52
  backlogView?: boolean;
52
53
  tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
53
54
  peerFilter?: string; kindFilter?: string; project?: string;
@@ -348,6 +349,12 @@ export function budgetFields(budget: any, now: number): [string, unknown][] {
348
349
  });
349
350
  return [["quota", lines.length ? lines : typeof budget.note === "string" ? budget.note : "no reading"], ...(lines.length && typeof budget.note === "string" ? [["quota note", budget.note] as [string, unknown]] : [])];
350
351
  }
352
+ /** #784 AC3: the Peers detail carries the planner WIP split as one line (`1 of 2: active 1, review-wait 0, reserved 0`); a hub that sends none adds nothing. */
353
+ function peerDetailRow(s: ConsoleState, row: any): Record<string, unknown> {
354
+ const wip = s.taskWip?.[row.id];
355
+ const split = wip && [wip.active, wip.reviewWait, wip.reserved].every(n => Number.isSafeInteger(n)) ? `${wip.wip} of ${wip.wipLimit}: active ${wip.active}, review-wait ${wip.reviewWait}, reserved ${wip.reserved}` : undefined;
356
+ return { ...row, ...(split ? { "planner WIP": split } : {}) };
357
+ }
351
358
  const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
352
359
  if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
353
360
  const { budget, ...rest } = detail as Record<string, unknown>;
@@ -794,7 +801,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
794
801
  if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
795
802
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
796
803
  else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
797
- else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
804
+ else { s.detail = s.panel === 5 ? String(row.text) : s.panel === 1 ? peerDetailRow(s, row) : { ...row }; s.detailOffset = 0; }
798
805
  return done();
799
806
  }
800
807
  if (s.mode === "panels" && item !== undefined && item !== null) {