@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.
- package/CHANGELOG.md +35 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -6
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +11 -2
- package/docs/agent-notes/tests.md +7 -3
- package/docs/dogfood.md +22 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +162 -35
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +15 -5
- package/docs/sdlc.md +29 -3
- package/docs/security.md +44 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +24 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +364 -98
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +198 -71
- package/src/cli/output.ts +11 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +31 -2
- package/src/hub/check-runner.ts +385 -25
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +3 -3
- package/src/hub/daemon.ts +530 -87
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +21 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +67 -0
- package/src/hub/operational-view.ts +137 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +81 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +378 -27
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/local/tools.ts +17 -4
- 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
|
|
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
|
-
//
|
|
404
|
-
const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input }, 3_605_000);
|
|
405
|
-
|
|
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 {
|
package/src/adapters/compat.ts
CHANGED
|
@@ -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-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
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
|
|
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
|
|
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;
|
|
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
|
-
|
|
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,
|
package/src/adapters/pi.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/adapters/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
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
|
+
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|