@staix/agent-hub 0.27.0 → 0.28.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 +23 -0
- package/README.md +11 -3
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +6 -5
- package/docs/design/adapters.md +76 -0
- package/docs/design/agent-hub-architecture.md +67 -0
- package/docs/design/agent-hub.md +52 -0
- package/docs/design/bus.md +48 -0
- package/docs/design/task-board.md +86 -0
- package/docs/dogfood.md +16 -1
- package/docs/operations.md +308 -12
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +8 -3
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-02-0.28.0.md +54 -0
- package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
- package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
- package/docs/verification/2026-11-02-control-write-after.json +139 -0
- package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
- package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
- package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
- package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
- package/docs/verified.json +13 -13
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +338 -112
- package/src/adapters/claude-channel.ts +12 -2
- package/src/adapters/compat.ts +1 -1
- package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
- package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
- package/src/adapters/jcode.ts +37 -4
- package/src/adapters/qwen-dual.ts +3 -2
- package/src/cli/agents.ts +4 -2
- package/src/cli/bench.ts +4 -2
- package/src/cli/board-read.ts +25 -0
- package/src/cli/claude-homes.ts +2 -2
- package/src/cli/console-state.ts +8 -3
- package/src/cli/help.ts +6 -5
- package/src/cli/identity.ts +9 -1
- package/src/cli/init-prompt.ts +179 -0
- package/src/cli/init-settings.ts +229 -0
- package/src/cli/init.ts +102 -9
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +106 -35
- package/src/cli/output.ts +14 -0
- package/src/cli/setup-homes.ts +18 -3
- package/src/cli/specs.ts +150 -0
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/board.ts +56 -3
- package/src/hub/bus.ts +54 -7
- package/src/hub/child-process.ts +6 -4
- package/src/hub/config-apply.ts +4 -1
- package/src/hub/config-edit.ts +1 -1
- package/src/hub/config-tier.ts +1 -0
- package/src/hub/control-client.ts +28 -4
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +304 -56
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/hub-tools.ts +10 -6
- package/src/hub/peers.ts +1 -1
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- package/src/hub/settings.ts +2 -0
- package/src/hub/specs-config.ts +43 -0
- package/src/hub/specs-task.ts +108 -0
- package/src/hub/specs.ts +1008 -0
- package/src/hub/task-list-page.ts +75 -0
- package/src/hub/task-sweep.ts +10 -1
- package/src/hub/tasks.ts +448 -40
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/ui/office-model.ts +17 -1
- package/src/ui/office-space.ts +1 -1
- package/templates/config.json +1 -0
- package/templates/profiles/dev.json +1 -0
- package/templates/profiles/general.json +1 -0
- package/templates/profiles/research.json +1 -0
- package/templates/profiles/writing.json +1 -0
- package/templates/specs.block.md +5 -0
|
@@ -13,6 +13,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { ControlClient, INVOCATION_CONTROL_TIMEOUT_MS } from "../hub/control-client.ts";
|
|
16
|
+
import { toolWriteOperation } from "../hub/tool-request.ts";
|
|
16
17
|
import { channelProject } from "../hub/channel-project.ts";
|
|
17
18
|
import { VERSION } from "../version.ts";
|
|
18
19
|
import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
|
|
@@ -394,7 +395,7 @@ function toolListFor(lifecycle: Awaited<ReturnType<typeof activeLifecycle>>) {
|
|
|
394
395
|
? [
|
|
395
396
|
{
|
|
396
397
|
name: "hub_delivery_done",
|
|
397
|
-
description: "Explicitly complete one handled channel delivery using its delivery_id and delivery_generation metadata. Does not change task state.
|
|
398
|
+
description: "Explicitly complete one handled channel delivery using its delivery_id and delivery_generation metadata. Does not change task state. If a prior result was outcome unknown, read the queue first and retry only if the delivery is still unresolved, with the same delivery_id and delivery_generation. Never complete an unhandled delivery.",
|
|
398
399
|
inputSchema: { type: "object", properties: { delivery_id: { type: "string" }, delivery_generation: { type: "string" } }, required: ["delivery_id", "delivery_generation"], additionalProperties: false },
|
|
399
400
|
},
|
|
400
401
|
]
|
|
@@ -443,6 +444,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
443
444
|
if (!hub) return text(offline());
|
|
444
445
|
const a = args ?? {};
|
|
445
446
|
const result = await hub.request({ t: "delivery_complete", deliveryId: a.delivery_id, generation: a.delivery_generation });
|
|
447
|
+
if (result.outcome === "unknown") return text(typeof result.error === "string" ? result.error : "Write outcome unknown. Inspect the delivery before another write; call again only if it is absent, with the same delivery id and generation.");
|
|
446
448
|
return text(result.ok ? "delivery completed" : `not completed: ${result.error}`);
|
|
447
449
|
}
|
|
448
450
|
if (name === "hub_inbox" && (!toolsMode || permissionTool)) {
|
|
@@ -483,7 +485,15 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
483
485
|
if (!hub) return text(offline());
|
|
484
486
|
// #445 C2: a daemon that predates invocations would answer with a generic unknown-operation error; say what is missing instead.
|
|
485
487
|
if (name === "hub_invoke" && !hub.features.includes("invocation")) return { ...text("error: this hub does not support invocations; restart it with the current agent-hub release (ahub restart)"), isError: true };
|
|
486
|
-
const
|
|
488
|
+
const rawArgs = args ?? {}, operation = toolWriteOperation({ t: "task", op: name });
|
|
489
|
+
let taskArgs = rawArgs, requestId: unknown;
|
|
490
|
+
if (operation && Object.hasOwn(rawArgs, "request_id")) {
|
|
491
|
+
requestId = rawArgs.request_id;
|
|
492
|
+
const { request_id: _requestId, ...withoutRequestId } = rawArgs;
|
|
493
|
+
taskArgs = withoutRequestId;
|
|
494
|
+
}
|
|
495
|
+
const res = await hub.request({ t: "task", op: name, args: taskArgs, ...(requestId !== undefined ? { requestId } : {}) }, name === "hub_invoke" ? INVOCATION_CONTROL_TIMEOUT_MS : undefined, name === "hub_invoke" ? signal : undefined);
|
|
496
|
+
if (res.outcome === "unknown") return text(typeof res.error === "string" ? res.error : "Write outcome unknown. Inspect the task before another write; call again only if it is absent, with the same request_id and arguments.");
|
|
487
497
|
return { ...text(res.ok ? res.text : `error: ${res.error}`), ...(res.ok ? {} : { isError: true }) };
|
|
488
498
|
}
|
|
489
499
|
return text(`unknown tool ${name}`);
|
package/src/adapters/compat.ts
CHANGED
|
@@ -40,7 +40,7 @@ export const COMPAT = {
|
|
|
40
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
41
|
receipt: "docs/verification/2026-10-31-claude-implementer-2.1.316.md" },
|
|
42
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; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md
|
|
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",
|
|
43
|
+
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0", "0.166.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
44
44
|
parse: out => plain(out.trim().replace(/^codex-cli /, "")),
|
|
45
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
46
|
receipt: "docs/verification/2026-10-31-codex-0166-reviewer-isolation.md" },
|
|
@@ -14,6 +14,8 @@ export interface InstalledPlugin {
|
|
|
14
14
|
id: string;
|
|
15
15
|
version?: string;
|
|
16
16
|
installPath?: string;
|
|
17
|
+
scope?: string;
|
|
18
|
+
projectPath?: string;
|
|
17
19
|
}
|
|
18
20
|
export interface Marketplace {
|
|
19
21
|
name: string;
|
|
@@ -45,26 +47,43 @@ const read = (p: string) => {
|
|
|
45
47
|
}
|
|
46
48
|
};
|
|
47
49
|
|
|
48
|
-
export type PluginState =
|
|
50
|
+
export type PluginState =
|
|
51
|
+
| { state: "missing"; notices?: string[] }
|
|
52
|
+
| { state: "current"; version: string; notices?: string[] }
|
|
53
|
+
| { state: "stale"; version: string; why: string; notices?: string[] };
|
|
54
|
+
|
|
55
|
+
const safeDisplayValue = (value: unknown): string | undefined =>
|
|
56
|
+
typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value) ? value : undefined;
|
|
57
|
+
|
|
58
|
+
function nonUserPluginNotices(plugins: InstalledPlugin[]): string[] {
|
|
59
|
+
return plugins.filter(plugin => plugin.id === PLUGIN && plugin.scope !== "user").map(plugin => {
|
|
60
|
+
const scope = plugin.scope === "project" || plugin.scope === "local" ? plugin.scope : "unknown-scope";
|
|
61
|
+
const path = safeDisplayValue(plugin.projectPath) ?? safeDisplayValue(plugin.installPath);
|
|
62
|
+
const version = safeDisplayValue(plugin.version) ?? "unknown";
|
|
63
|
+
return `${scope} plugin row preserved${path ? ` at ${path}` : ""} (version ${version}); setup never removes non-user scopes`;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
49
66
|
|
|
50
67
|
/**
|
|
51
68
|
* Is the installed Claude plugin the one this hub ships? The version is compared, and so is the bundle itself: Claude
|
|
52
69
|
* Code runs a cached copy, and a copy taken from an earlier build of the same version speaks an older wire protocol.
|
|
53
70
|
*/
|
|
54
71
|
export function pluginState(plugins: InstalledPlugin[], packageRoot: string): PluginState {
|
|
55
|
-
const
|
|
56
|
-
|
|
72
|
+
const notices = nonUserPluginNotices(plugins);
|
|
73
|
+
const mine = plugins.find((p) => p.id === PLUGIN && p.scope === "user");
|
|
74
|
+
if (!mine) return { state: "missing", ...(notices.length ? { notices } : {}) };
|
|
57
75
|
const version = mine.version ?? "unknown";
|
|
58
|
-
if (version !== VERSION) return { state: "stale", version, why: `version ${version}, this hub is ${VERSION}
|
|
76
|
+
if (version !== VERSION) return { state: "stale", version, why: `version ${version}, this hub is ${VERSION}`, ...(notices.length ? { notices } : {}) };
|
|
59
77
|
const shipped = read(join(packageRoot, "plugins", "agent-hub", "server.js"));
|
|
60
78
|
const cached = mine.installPath ? read(join(mine.installPath, "server.js")) : undefined;
|
|
61
|
-
if (shipped !== undefined && cached !== undefined && shipped !== cached) return { state: "stale", version, why: "same version, but the cached bundle differs from the one this hub ships" };
|
|
62
|
-
return { state: "current", version };
|
|
79
|
+
if (shipped !== undefined && cached !== undefined && shipped !== cached) return { state: "stale", version, why: "same version, but the cached bundle differs from the one this hub ships", ...(notices.length ? { notices } : {}) };
|
|
80
|
+
return { state: "current", version, ...(notices.length ? { notices } : {}) };
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
export interface SetupStep {
|
|
66
84
|
why: string;
|
|
67
85
|
argv: string[];
|
|
86
|
+
blocked?: true;
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
/**
|
|
@@ -74,11 +93,14 @@ export interface SetupStep {
|
|
|
74
93
|
export function nextStep(plugins: InstalledPlugin[], marketplaces: Marketplace[], packageRoot: string): SetupStep | undefined {
|
|
75
94
|
const root = real(packageRoot);
|
|
76
95
|
const market = marketplaces.find((m) => m.name === MARKETPLACE);
|
|
77
|
-
if (market && real(market.path ?? "") !== root)
|
|
96
|
+
if (market && real(market.path ?? "") !== root) {
|
|
97
|
+
if (plugins.some(plugin => plugin.id === PLUGIN && plugin.scope !== "user")) return { why: `the ${MARKETPLACE} marketplace points at another source; removing it may remove non-user plugin rows`, argv: [], blocked: true };
|
|
98
|
+
return { why: `the ${MARKETPLACE} marketplace points at ${market.path ?? "another source"}, not at this package`, argv: ["claude", "plugin", "marketplace", "remove", MARKETPLACE] };
|
|
99
|
+
}
|
|
78
100
|
if (!market) return { why: "register this package as a Claude Code plugin marketplace", argv: ["claude", "plugin", "marketplace", "add", root] };
|
|
79
101
|
const state = pluginState(plugins, root);
|
|
80
|
-
if (state.state === "missing") return { why: "install the channel plugin", argv: ["claude", "plugin", "install", PLUGIN] };
|
|
102
|
+
if (state.state === "missing") return { why: "install the user-scope channel plugin", argv: ["claude", "plugin", "install", PLUGIN, "--scope", "user"] };
|
|
81
103
|
// Uninstall rather than update: with an unchanged version number an update has nothing to do.
|
|
82
|
-
if (state.state === "stale") return { why: `replace the installed plugin (${state.why})`, argv: ["claude", "plugin", "uninstall", PLUGIN] };
|
|
104
|
+
if (state.state === "stale") return { why: `replace the installed user-scope plugin (${state.why})`, argv: ["claude", "plugin", "uninstall", PLUGIN, "--scope", "user"] };
|
|
83
105
|
return undefined;
|
|
84
106
|
}
|
|
@@ -3,10 +3,40 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
4
4
|
import type { ControlClient } from "../../hub/control-client.ts";
|
|
5
5
|
import type { JcodeTuiBinding, JcodeTuiState } from "../jcode.ts";
|
|
6
|
+
import { JCODE_HEADLESS_AFTER_RECOVERY } from "../jcode.ts";
|
|
6
7
|
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
7
8
|
|
|
8
9
|
const WAITING = "jcode's TUI opens after its first hub turn; send it work, or Ctrl-C";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* #1085: what to tell the person for each cause class the daemon returns as `code`. Fixed text: the daemon's reply carries a code, never a path or id, and nothing else is echoed.
|
|
13
|
+
* A session that was loaded from a record (`resumed`, crash or controlled recovery) is running headless: the way back to its TUI is stop, then start.
|
|
14
|
+
*/
|
|
15
|
+
export function jcodeTuiCauseMessage(code: unknown, resumed = false): string | undefined {
|
|
16
|
+
// The cause sentence is per class (AC1). The next step is per class too, except that a session loaded from a record is
|
|
17
|
+
// running headless from recovery, whatever refused the TUI: its way back is always stop, then start (AC2).
|
|
18
|
+
const recoveredStep = `${JCODE_HEADLESS_AFTER_RECOVERY}`;
|
|
19
|
+
const step = (own: string) => resumed ? recoveredStep : own;
|
|
20
|
+
switch (code) {
|
|
21
|
+
case "launch_changed": return `Jcode was restarted while ahub jcode was starting its TUI; ${step("run ahub jcode again")}`;
|
|
22
|
+
case "server_not_running": return `Jcode's owned server is not running; ${step("run ahub stop jcode, then ahub jcode")}`;
|
|
23
|
+
case "socket_changed": return `Jcode's private socket was replaced by another process; ${step("run ahub stop jcode, then ahub jcode")}`;
|
|
24
|
+
case "session_unknown": return `Jcode has not reported a session yet; ${step("wait for it to attach, then run ahub jcode again")}`;
|
|
25
|
+
case "session_proof_refused": return `Jcode's saved session failed the hub's safety check (not a plain file, too large, or another session); ${step("run ahub stop jcode, then ahub jcode for a new session")}`;
|
|
26
|
+
case "hub_busy": return "the hub is stopping, recovering or changing its roster; wait for ahub status to settle, then run ahub jcode again";
|
|
27
|
+
case "not_running": return "Jcode is not running under this hub; run ahub jcode again";
|
|
28
|
+
case "mcp_stale": return "the project's Jcode MCP entry is missing or stale; run ahub jcode again to rewrite it";
|
|
29
|
+
default: return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Names the cause class the daemon returned (#1085). An older daemon sends no code: the caller's generic message stands. */
|
|
34
|
+
function refuseTuiCause(reply: { ok?: unknown; code?: unknown; resumed?: unknown }, fail: NativeCliEntryContext["fail"]): void {
|
|
35
|
+
if (reply.ok) return;
|
|
36
|
+
const message = jcodeTuiCauseMessage(reply.code, reply.resumed === true);
|
|
37
|
+
if (message) fail(message);
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
/** The daemon names the one session option that differs from the attached saved session (#559 N2, #574). */
|
|
11
41
|
function refuseNamedOption(reply: { ok?: unknown; option?: unknown }, fail: NativeCliEntryContext["fail"]): void {
|
|
12
42
|
if (!reply.ok && typeof reply.option === "string" && ["--session-id", "--model", "--provider-profile"].includes(reply.option)) fail(`Jcode is already running with a different ${reply.option}; run ahub jcode without it, or stop jcode first`);
|
|
@@ -20,6 +50,7 @@ export async function waitForJcodeTui(hub: ControlClient, fail: NativeCliEntryCo
|
|
|
20
50
|
const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", binding, args: options });
|
|
21
51
|
if (signal.aborted) return;
|
|
22
52
|
refuseNamedOption(reply, fail);
|
|
53
|
+
refuseTuiCause(reply, fail);
|
|
23
54
|
if (!reply.ok || !reply.binding || !["waiting", "ready"].includes(reply.state)) fail("Jcode launch changed or its native saved session is unavailable; retry from a terminal");
|
|
24
55
|
current = reply as JcodeTuiState;
|
|
25
56
|
}
|
|
@@ -78,6 +109,7 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
78
109
|
if (!hub.features.includes("native_tui_launch") || mode !== "tui") return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2));
|
|
79
110
|
const reply = await hub.request({ t: "native_tui_launch", peer: "jcode", args: options });
|
|
80
111
|
refuseNamedOption(reply, fail);
|
|
112
|
+
refuseTuiCause(reply, fail);
|
|
81
113
|
if (!reply.ok || reply.state !== "ready") fail("Jcode's selected native session is not saved and ready; run ahub jcode to wait for its first hub turn");
|
|
82
114
|
return console.log(JSON.stringify(reply.launch, null, 2));
|
|
83
115
|
}
|
|
@@ -102,6 +134,7 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
102
134
|
// no terminal recovery adapter (#564).
|
|
103
135
|
const confirmed = await hub.request({ t: "native_tui_launch", peer: "jcode", binding: ready.binding, args: options });
|
|
104
136
|
if (interruptedWait()) return;
|
|
137
|
+
refuseTuiCause(confirmed, fail);
|
|
105
138
|
if (!confirmed.ok || confirmed.state !== "ready") fail("Jcode launch changed before its terminal opened");
|
|
106
139
|
// ponytail: jcode 0.98.1's top-level TUI spawns a shared server (no --temporary-server or --owner-pid) when nothing
|
|
107
140
|
// answers on the dialed socket at startup, and on its reload-handoff reconnect. A hub stop or an owned-server crash
|
|
@@ -43,9 +43,9 @@ export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
43
43
|
// The daemon's launch env carries only the peer marker: Qwen runs in the person's own terminal and needs their PATH, HOME (its settings and sessions) and the key its provider
|
|
44
44
|
// names, none of which the daemon may copy. So the person's environment is the base and the daemon's marker goes over it. (Measured: without this the exec found no `qwen`.)
|
|
45
45
|
// That terminal may itself be inside another agent (a Claude or Codex shell): its vendor markers and every hub marker but AGENTHUB_HOME are dropped, so Qwen and its hub MCP
|
|
46
|
-
// server never carry another peer's identity, launch id or mode.
|
|
46
|
+
// server never carry another peer's identity, launch id or mode. Its ORCA_* values belong to this person-owned pane and are retained.
|
|
47
47
|
const person = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("AGENTHUB_") || name === "AGENTHUB_HOME"));
|
|
48
|
-
const env = { ...peerChildEnv(peer, person), ...verified.env };
|
|
48
|
+
const env = { ...peerChildEnv(peer, person, { preserveOrca: true }), ...verified.env };
|
|
49
49
|
delete env.AGENTHUB_RECOVERY_OPERATION; delete env.AGENTHUB_UNATTENDED;
|
|
50
50
|
console.error("Qwen runs in this terminal, driven by the hub through its dual-output files. Limits: a hub deny ends the turn without an answer, and a stalled turn is stopped only by pressing Esc here.");
|
|
51
51
|
return execWithEnv(verified.cmd, verified.args, env);
|
package/src/adapters/jcode.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface JcodeOptions extends Omit<AcpOptions, "cmd" | "qwen025" | "expe
|
|
|
19
19
|
provider?: string;
|
|
20
20
|
/** How long a native steer may take to be acknowledged (#572); default 5 s. */
|
|
21
21
|
steerTimeoutMs?: number;
|
|
22
|
+
/** #1085: what the hub knows that the adapter does not, asked when status is built: is this peer's start mode tui, and did this hub run start after a crash report. */
|
|
23
|
+
tuiHintContext?: () => { tui: boolean; afterCrash: boolean };
|
|
22
24
|
}
|
|
23
25
|
export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string, provider?: string, peer = "jcode"): string[] {
|
|
24
26
|
if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
|
|
@@ -73,6 +75,16 @@ function savedSession(home: string, id: string): boolean {
|
|
|
73
75
|
export class JcodeOptionMismatch extends Error {
|
|
74
76
|
constructor(readonly option: "--session-id" | "--model" | "--provider-profile") { super("Jcode launch options differ from its running session"); }
|
|
75
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* #1085: why an owned Jcode refused a TUI launch. A closed set of causes, never a path, session id or launch id:
|
|
80
|
+
* the daemon logs the code and returns it, and the launcher turns it into a message with a next step.
|
|
81
|
+
*/
|
|
82
|
+
export type JcodeTuiRefusalCode = "launch_changed" | "server_not_running" | "socket_changed" | "session_unknown" | "session_proof_refused";
|
|
83
|
+
export class JcodeTuiRefusal extends Error {
|
|
84
|
+
constructor(readonly code: JcodeTuiRefusalCode) { super(`owned Jcode TUI launch refused: ${code}`); }
|
|
85
|
+
}
|
|
86
|
+
/** #1085: said by `ahub status` while a recovered headless Jcode has not yet opened its TUI, and by the launcher when `ahub jcode` finds it running headless. */
|
|
87
|
+
export const JCODE_HEADLESS_AFTER_RECOVERY = "jcode is running headless after recovery; run ahub stop jcode, then ahub jcode to get its TUI back";
|
|
76
88
|
const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
|
|
77
89
|
/** #691: the verdict lives in `src/adapters/compat.ts` (line, verified versions, the person's trust); never pin here. */
|
|
78
90
|
export const jcodeVersionSupported = (version: unknown): boolean => assessVersion("jcode", version).ok;
|
|
@@ -194,6 +206,8 @@ export class JcodePeer extends AcpPeer {
|
|
|
194
206
|
private ending = false;
|
|
195
207
|
private preserveRoot = false;
|
|
196
208
|
private stopPromise: Promise<void> | undefined;
|
|
209
|
+
/** #1085: a ready TUI launch was served for this owned launch, so the person is on the way to its TUI. */
|
|
210
|
+
private tuiServed = false;
|
|
197
211
|
|
|
198
212
|
constructor(private readonly jcode: JcodeOptions) {
|
|
199
213
|
if (!isAbsolute(jcode.home)) throw new Error("jcode_home must be an absolute native data path");
|
|
@@ -215,6 +229,20 @@ export class JcodePeer extends AcpPeer {
|
|
|
215
229
|
|
|
216
230
|
ownsLaunch(launchId: unknown): boolean { return !this.ending && typeof launchId === "string" && launchId === this.launchId; }
|
|
217
231
|
|
|
232
|
+
/**
|
|
233
|
+
* #1085: after recovery this Jcode can be live and headless while its start mode is tui and no `ahub jcode` has reached its TUI yet: say how to get the TUI back.
|
|
234
|
+
* Only while it is live, only after a crash report or a resumed session, and never once a ready TUI launch was served. Fixed text: no id, path or socket.
|
|
235
|
+
*/
|
|
236
|
+
get statusNotice(): { code: "jcode-headless-after-recovery"; message: string } | undefined {
|
|
237
|
+
if (this.state === "offline" || this.tuiServed) return undefined;
|
|
238
|
+
const context = this.jcode.tuiHintContext?.();
|
|
239
|
+
if (!context?.tui || !(context.afterCrash || this.resumedSession)) return undefined;
|
|
240
|
+
return { code: "jcode-headless-after-recovery", message: JCODE_HEADLESS_AFTER_RECOVERY };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** #1085: this owned launch loaded a recorded session id (crash or controlled recovery, or the person's --session-id) instead of making a new one. A boolean only, never the id. */
|
|
244
|
+
get resumedSession(): boolean { return typeof this.jcode.resumeSessionId === "string" && this.jcode.resumeSessionId.length > 0; }
|
|
245
|
+
|
|
218
246
|
override async start(): Promise<void> {
|
|
219
247
|
// The native loader otherwise imports Codex MCP into the user's store on first use.
|
|
220
248
|
if (!existsSync(join(this.jcode.home, "mcp.json"))) throw new Error("initialize Jcode's global MCP store with its native client before enabling this peer; the hub never initializes or copies it");
|
|
@@ -315,16 +343,21 @@ export class JcodePeer extends AcpPeer {
|
|
|
315
343
|
tuiLaunch(expected?: JcodeTuiBinding, options: { model?: string; providerProfile?: string; sessionId?: string } = {}): JcodeTuiState {
|
|
316
344
|
const sessionId = super.recoveryMetadata().sessionId;
|
|
317
345
|
const socket = lstatSync(this.socket, { throwIfNoEntry: false });
|
|
318
|
-
|
|
319
|
-
|
|
346
|
+
// One cause per check, in the order the checks ran before #1085, so the same states are refused as before.
|
|
347
|
+
if (this.ending || !this.server || this.server.exitCode !== null || this.server.signalCode !== null || this.state === "offline") throw new JcodeTuiRefusal("server_not_running");
|
|
348
|
+
if (!socket?.isSocket() || socket.dev !== this.socketIdentity?.dev || socket.ino !== this.socketIdentity.ino) throw new JcodeTuiRefusal("socket_changed");
|
|
349
|
+
if (typeof sessionId !== "string") throw new JcodeTuiRefusal("session_unknown");
|
|
320
350
|
const binding = { launchId: this.launchId, sessionId };
|
|
321
|
-
if (expected && (expected.launchId !== binding.launchId || expected.sessionId !== binding.sessionId)) throw new
|
|
351
|
+
if (expected && (expected.launchId !== binding.launchId || expected.sessionId !== binding.sessionId)) throw new JcodeTuiRefusal("launch_changed");
|
|
322
352
|
// Names the option, never its value: the flag is the person's own and carries nothing private (#559).
|
|
323
353
|
const differs = options.sessionId !== undefined && options.sessionId !== sessionId ? "--session-id"
|
|
324
354
|
: options.model !== undefined && options.model !== this.jcode.launchModel ? "--model"
|
|
325
355
|
: options.providerProfile !== undefined && options.providerProfile !== this.jcode.providerProfile ? "--provider-profile" : undefined;
|
|
326
356
|
if (differs) throw new JcodeOptionMismatch(differs);
|
|
327
|
-
|
|
357
|
+
let saved: boolean;
|
|
358
|
+
try { saved = savedSession(this.jcode.home, sessionId); } catch { throw new JcodeTuiRefusal("session_proof_refused"); }
|
|
359
|
+
if (!saved) return { state: "waiting", binding };
|
|
360
|
+
this.tuiServed = true;
|
|
328
361
|
const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile, this.jcode.provider);
|
|
329
362
|
// The TUI is the person's own client of this peer's server. The shared project MCP entry names no peer (#685), so every launch
|
|
330
363
|
// path supplies its own id: the base Jcode's too, or its MCP child would fall back to the channel default.
|
|
@@ -2,7 +2,8 @@ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSy
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, sanitize, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import { BasePeer, type DeliveryReceipt } from "../hub/peers.ts";
|
|
5
|
-
import { peerChildEnv, processTable, type ProcRow } from "../hub/child-process.ts";
|
|
5
|
+
import { childEnv, peerChildEnv, processTable, type ProcRow } from "../hub/child-process.ts";
|
|
6
|
+
import { withoutOrcaEnv } from "../hub/orca-env.ts";
|
|
6
7
|
import { processSignature } from "../pi/process-signature.ts";
|
|
7
8
|
import { assessVersion } from "./compat.ts";
|
|
8
9
|
import { qwenSessionId } from "./definitions/qwen-native.ts";
|
|
@@ -97,7 +98,7 @@ export function isQwenTaskNotification(text: string): boolean { return text.leng
|
|
|
97
98
|
export async function qwenDualVersionAsync(cmd: readonly string[], cwd: string): Promise<{ ok: true; version: string; note?: string } | { ok: false; reason: string }> {
|
|
98
99
|
const [bin, ...prefix] = cmd.filter(word => word !== "--acp");
|
|
99
100
|
try {
|
|
100
|
-
const child = Bun.spawn([bin ?? "qwen", ...prefix, "--version"], { cwd, stdout: "pipe", stderr: "ignore", stdin: "ignore", env:
|
|
101
|
+
const child = Bun.spawn([bin ?? "qwen", ...prefix, "--version"], { cwd, stdout: "pipe", stderr: "ignore", stdin: "ignore", env: withoutOrcaEnv(childEnv(process.env)) });
|
|
101
102
|
const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
|
|
102
103
|
const out = await new Response(child.stdout).text(); await child.exited; clearTimeout(timer);
|
|
103
104
|
const verdict = assessVersion("qwen-dual", out.trim());
|
package/src/cli/agents.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { assessVersion, COMPAT, describe, isCompatId, readTrust, trustFile, writeTrust, type CompatId, type CompatVerdict } from "../adapters/compat.ts";
|
|
3
|
+
import { childEnv } from "../hub/child-process.ts";
|
|
4
|
+
import { withoutOrcaEnv } from "../hub/orca-env.ts";
|
|
3
5
|
|
|
4
6
|
/** #691: what `ahub agents` and `ahub doctor` show for each gated native CLI. */
|
|
5
7
|
export interface AgentVersionRow { id: CompatId; executable?: string; verdict: CompatVerdict; policy: string }
|
|
@@ -25,7 +27,7 @@ export function agentVersions(commands: Partial<Record<CompatId, readonly string
|
|
|
25
27
|
return (Object.keys(COMPAT) as CompatId[]).map(id => {
|
|
26
28
|
const target = probeTarget(id, commands);
|
|
27
29
|
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 } });
|
|
30
|
+
const result = spawnSync(target.executable, target.args, { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: withoutOrcaEnv(childEnv({ ...process.env, ...PROBE_ENV })) });
|
|
29
31
|
return probeRow(id, target.executable, result.status === 0 ? result.stdout ?? "" : undefined, trusted);
|
|
30
32
|
});
|
|
31
33
|
}
|
|
@@ -42,7 +44,7 @@ const PROBE_EXIT_GRACE_MS = 250;
|
|
|
42
44
|
* The upgrade is a process group via child_process (as `jcodeVersionAsync` does).
|
|
43
45
|
*/
|
|
44
46
|
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" });
|
|
47
|
+
const child = Bun.spawn([executable, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore", env: withoutOrcaEnv(childEnv({ ...process.env, ...PROBE_ENV })), timeout: timeoutMs, killSignal: "SIGKILL" });
|
|
46
48
|
const reader = child.stdout.getReader();
|
|
47
49
|
const chunks: Uint8Array[] = [];
|
|
48
50
|
let total = 0, overflow = false, cancelled = false;
|
package/src/cli/bench.ts
CHANGED
|
@@ -284,9 +284,11 @@ async function runAttempt(task: SuiteTask, commit: string, repeat: number, run:
|
|
|
284
284
|
for (;;) {
|
|
285
285
|
let state: string | undefined;
|
|
286
286
|
try {
|
|
287
|
-
const list = await hub.request({ t: "task", op: "hub_task_list", args: {} }, 15_000);
|
|
287
|
+
const list = await hub.request({ t: "task", op: "hub_task_list", args: { ids: [id] } }, 15_000);
|
|
288
288
|
if (!list.ok) return done("error", { error: "hub stopped", hubTask: id, open: true });
|
|
289
|
-
|
|
289
|
+
// #1083: a current hub answers {tasks, total}; one from before the change ignores `ids` and answers the whole board as an array.
|
|
290
|
+
const parsed = JSON.parse(String(list.text)) as { id: number; state: string }[] | { tasks: { id: number; state: string }[] };
|
|
291
|
+
state = (Array.isArray(parsed) ? parsed : parsed.tasks).find((t) => t.id === id)?.state;
|
|
290
292
|
} catch { return done("error", { error: "hub stopped", hubTask: id, open: true }); }
|
|
291
293
|
if (state === "approved") break;
|
|
292
294
|
if (opts.stopped?.()) return done("error", { error: "interrupted", hubTask: id, open: true });
|
package/src/cli/board-read.ts
CHANGED
|
@@ -10,3 +10,28 @@ export function supportsBoardColumnMetadata(status: unknown): boolean {
|
|
|
10
10
|
const capability = (status as Record<string,unknown>).boardReadCapabilities;
|
|
11
11
|
return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).columnMetadata === true;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/** The most pages `ahub board` follows (200 rows each): a board larger than this is refused with a cause instead of read forever. */
|
|
15
|
+
export const BOARD_MAX_PAGES = 100;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* #1083: `hub_task_list` answers an agent with `{tasks, total, next?}` and the person's console, or a hub from before the change, with the bare array.
|
|
19
|
+
* Read the whole board either way: ask for full rows in the largest page, follow `next` until it is absent, and return one flat array of rows.
|
|
20
|
+
* A person's console answers the bare array to a request that names no paging argument, but this reader names them, so it reads both shapes.
|
|
21
|
+
*/
|
|
22
|
+
export async function readAllTasks(call: (args: Record<string, unknown>) => Promise<string>, args: Record<string, unknown>, paged = true): Promise<any[]> {
|
|
23
|
+
const rows: any[] = [];
|
|
24
|
+
let cursor: string | undefined;
|
|
25
|
+
for (let page = 0; page < BOARD_MAX_PAGES; page++) {
|
|
26
|
+
// A person's console is answered the bare array to a request with no paging argument, so it sends none and its request is unchanged; an agent shell is always paged.
|
|
27
|
+
const parsed: unknown = JSON.parse(await call(paged ? { ...args, detail: true, limit: 200, ...(cursor ? { cursor } : {}) } : args));
|
|
28
|
+
if (Array.isArray(parsed)) return parsed; // an older hub ignores the paging arguments and answers the whole board
|
|
29
|
+
const body = parsed as { tasks?: unknown; next?: unknown };
|
|
30
|
+
if (!body || !Array.isArray(body.tasks)) throw new Error("the hub's task list reply has neither rows nor a task array; upgrade or restart the hub (ahub status)");
|
|
31
|
+
rows.push(...body.tasks);
|
|
32
|
+
if (typeof body.next !== "string" || !body.next) return rows.sort((a, b) => a.id - b.id); // the bare array was in id order and the renderers print rows as given
|
|
33
|
+
if (body.next === cursor) throw new Error("the hub's task list cursor did not advance; run ahub status and retry");
|
|
34
|
+
cursor = body.next;
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`the board is larger than ${BOARD_MAX_PAGES * 200} tasks, more than ahub board reads; filter it with a state or --release`);
|
|
37
|
+
}
|
package/src/cli/claude-homes.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { lstatSync, statSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import { realPath } from "../hub/project.ts";
|
|
4
4
|
import { launcherOf, recordPath, MAX_COMMAND_MS, type ProcessIdentity } from "./terminal-recovery.ts";
|
|
5
|
-
import { CLAUDE_PLUGIN_INVENTORY, nextStep, pluginState, type InstalledPlugin, type Marketplace, type PluginState, type SetupStep } from "./setup.ts";
|
|
5
|
+
import { CLAUDE_PLUGIN_INVENTORY, PLUGIN, nextStep, pluginState, type InstalledPlugin, type Marketplace, type PluginState, type SetupStep } from "./setup.ts";
|
|
6
6
|
import { checkClaudeProfileHook, type ClaudeProfileHookCheck } from "./claude-profile-hook.ts";
|
|
7
7
|
|
|
8
8
|
export type ClaudeHomeSource = "environment" | "default" | "recorded-launch";
|
|
@@ -115,7 +115,7 @@ export async function checkClaudeHome(home: ClaudeHome, packageRoot: string, env
|
|
|
115
115
|
if (!marketplaces) return unknown();
|
|
116
116
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
117
117
|
if (!plugins.every(row => object(row) && typeof row.id === "string" && row.id.length <= 256 &&
|
|
118
|
-
(row.version === undefined || typeof row.version === "string" && row.version.length <= 64 && /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(row.version)) &&
|
|
118
|
+
(row.version === undefined || typeof row.version === "string" && row.version.length <= 64 && (row.id !== PLUGIN ? !/[\u0000-\u001f\u007f-\u009f]/.test(row.version) : /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(row.version))) &&
|
|
119
119
|
(row.installPath === undefined || validPath(row.installPath))) ||
|
|
120
120
|
!marketplaces.every(row => object(row) && typeof row.name === "string" && row.name.length <= 256 && (row.path === undefined || validPath(row.path)))) return unknown();
|
|
121
121
|
return { home, plugin: pluginState(plugins as InstalledPlugin[], packageRoot), next: nextStep(plugins as InstalledPlugin[], marketplaces as Marketplace[], packageRoot), hook };
|
package/src/cli/console-state.ts
CHANGED
|
@@ -637,10 +637,15 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
637
637
|
for (let i = reasonAt + 1; reasonAt >= 0 && i < parsed.length && !parsed[i]!.startsWith("--"); i++) words.push(parsed[i]!);
|
|
638
638
|
const reason = words.join(" ").trim();
|
|
639
639
|
if (!peer || !reason) throw new Error("queue discard needs --peer and --reason (it is recorded on every discarded delivery)");
|
|
640
|
-
|
|
641
|
-
const
|
|
640
|
+
// #1096: with no --state the CLI clears the peer's queued rows and its unsettled failed rows (the list this panel shows already hides settled ones), so the count names both. A hub without `queue_discard_states` cannot take that form: the command then names the one kind it can clear, queued, and the count follows it.
|
|
641
|
+
const bulk = !parsed.includes("--state") && s.features?.includes("queue_discard_states") === true;
|
|
642
|
+
const kinds = bulk ? ["queued", "failed"] : [state];
|
|
643
|
+
const counts = kinds.map(kind => [kind, s.queue.filter(row => row.peer === peer && row.state === kind).length] as const);
|
|
644
|
+
const count = counts.reduce((sum, [, n]) => sum + n, 0);
|
|
645
|
+
const what = bulk && counts.every(([, n]) => n > 0) ? counts.map(([kind, n]) => `${n} ${kind}`).join(" and ") : bulk ? `${count} ${counts.find(([, n]) => n > 0)?.[0] ?? "queued"}` : `${count} ${state}`;
|
|
646
|
+
const args = ["queue", "discard", "--peer", peer, ...(parsed.includes("--state") ? ["--state", state] : s.features !== undefined && !bulk ? ["--state", "queued"] : []), "--reason", reason, "--yes"];
|
|
642
647
|
s.history = [...s.history, input].slice(-(s.historyLimit ?? CONSOLE_HISTORY_LIMIT)); s.historyIndex = s.history.length;
|
|
643
|
-
s.confirm = { type: "command", args, prompt: `discard all ${
|
|
648
|
+
s.confirm = { type: "command", args, prompt: `discard all ${what} deliver${count === 1 ? "y" : "ies"} of ${peer}; no task or native turn is cancelled, other peers are untouched` };
|
|
644
649
|
return done();
|
|
645
650
|
}
|
|
646
651
|
const reviewProblem = reviewRefusal(parsed); // an empty changes note never reaches the hub (#711)
|
package/src/cli/help.ts
CHANGED
|
@@ -7,11 +7,12 @@ export interface HelpEntry { section: string; usage: string; description: string
|
|
|
7
7
|
|
|
8
8
|
const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
9
9
|
["Projects", [
|
|
10
|
+
["ahub specs list [--json] | check [--require-fresh] [--base <ref>] [--json] | covers <file...> | stamp <id-or-document...> [--yes]", "read design specs, validate their graph and source freshness, or find covering specs; runs without a daemon; check exits 1 on errors and, with --require-fresh, stale documents; stamp is person only (a terminal, never an agent): it shows old -> new, asks (--yes skips the question but still needs a terminal), and then changes only the verified-against line (or the stamps-file entry) to the full HEAD SHA, refused while the covered paths have uncommitted changes"],
|
|
10
11
|
["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
|
|
11
12
|
["ahub projects [--json] [--full] [--color=auto|always|never]", "list registered projects and live status"],
|
|
12
13
|
["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
|
|
13
14
|
["ahub setup [--yes] [--agents <a,b>]", "install or update the channel plugin and project-neutral hook in Claude configuration homes in use, preserving unrelated profile settings, ask once which agents ahub up starts (--agents answers it, --yes keeps the current choice or claude,codex, no terminal keeps the default), register the acon console plugin (console/plugins/ahub.json in the hub home, read at acon's next start), then run doctor"],
|
|
14
|
-
["ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block); a pack is a shipped one (writing-pack, analysis, ops) or a project file .agenthub/roles/<name>.pack.json; a new project inherits a trusted machine autonomy default and the agent set chosen at ahub setup (start.agents) into config.local.json"],
|
|
15
|
+
["ahub init [--profile dev|research|writing|general|<pack>] [--agents <a,b,...|none>] [--sdlc off|advise|enforce] [--coordination advisory|turn-free] [--memory|--no-memory] [--snapshots|--no-snapshots] [--pi|--no-pi] [--specs|--no-specs] [--spec-review off|report|require] [--autonomy inherit|off] [--shadow] [--release-guard] [--yes] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block); a pack is a shipped one (writing-pack, analysis, ops) or a project file .agenthub/roles/<name>.pack.json; a new project inherits a trusted machine autonomy default and the agent set chosen at ahub setup (start.agents) into config.local.json. Settings flags (#1065): --agents sets start.agents (none starts no agent) and --autonomy off skips the autonomy inheritance, both in config.local.json; --sdlc sets sdlc.toml mode (off writes no file on a new project); --coordination, --memory, --snapshots and --pi set their keys in config.json. On an existing project only a flag you give changes anything, and --dry-run shows each change as old -> new (--dry-run --json adds a settings array); a value equal to the current one is a no-op, an invalid value is refused with the allowed ones and nothing is written. --specs/--no-specs and --spec-review set the design-specs keys (and the AGENTS.md specs block). A person at a terminal who runs ahub init on a new project with no flag that sets anything and no --yes is asked each setting in turn with its default in brackets (Enter keeps it, a wrong answer asks again, and a third wrong answer ends init with nothing written), sees the plan and answers y/N; N, Ctrl-C and Ctrl-D write nothing. A peer, a live test, a pipe and --dry-run are never asked, and --yes takes the defaults. Executables, secrets, models and permissions stay file edits"],
|
|
15
16
|
["ahub --live-test [--project <path|id>] <command> [args...]", "user-authorized operational console delegation; ordinary calls retain peer identity; person-authored content refuses"],
|
|
16
17
|
["ahub help [command]", "this help, or one command's entries; also --help and -h"],
|
|
17
18
|
["ahub version | --version", "print the installed version"],
|
|
@@ -38,7 +39,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
38
39
|
["ahub sdlc costs [owner/repo] [--json] [--color=auto|always|never]", "read bounded planner cost metrics for linked merged PRs"],
|
|
39
40
|
["ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>] [--json] [--color=auto|always|never]", "read SDLC metrics and control band tiers from board history (planner or person; read-only)"],
|
|
40
41
|
["ahub backlog groom | return <task> <revision> <reason...> [--json] [--color=auto|always|never]", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
|
|
41
|
-
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
42
|
+
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts, plus a specs line (errors and stale documents) when the project has design specs"],
|
|
42
43
|
["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
|
|
43
44
|
["ahub logs [-f]", "person: last 100 hub.log lines (-f starts at 10); conductor: bounded public lines and follow while authority remains"],
|
|
44
45
|
["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models, memory library and memory capture setup (read from `amem setup <tool> --json`, a preview) this project uses, and the runtime capture pruning ledger; warn when the CLI or plugin is older than its running daemon"],
|
|
@@ -79,15 +80,15 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
79
80
|
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "validated settings: conductor set is limited to exact live operational keys; undo/reload and other keys are person only; --preview saves nothing; conductor writes keep the person undo record"],
|
|
80
81
|
["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, research.runners, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
|
|
81
82
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
82
|
-
["ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]", "inspect durable deliveries; open to any agent as public queue metadata, held bodies stay with the person or conductor"],
|
|
83
|
+
["ahub queue list [--peer <id>] [--all] [--json] [--full] [--color=auto|always|never]", "inspect durable deliveries; open to any agent as public queue metadata, held bodies stay with the person or conductor; rows a resolution already settled (a retried parent, a discarded or completed row) are hidden and counted on stderr, --all shows them"],
|
|
83
84
|
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "person or configured conductor: inspect one delivery and revision; agent output uses the public view"],
|
|
84
85
|
["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N] [--json] [--color=auto|always|never]", "person or configured conductor: resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show; a queued delivery can be discarded while its peer is busy, and a refusal names its cause"],
|
|
85
|
-
["ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes] [--json]", "person or configured conductor: without --yes count one peer's
|
|
86
|
+
["ahub queue discard --peer <id> --reason <text> [--state queued|needs_review|failed] [--yes] [--json]", "person or configured conductor: without --yes count one peer's deliveries; with --yes discard them all with the reason, one recorded discard each. With no --state it takes the peer's queued and unsettled failed deliveries (a failed row a resolution already settled is left alone); --state names exactly one kind, needs_review only that way; other peers' queues are untouched"],
|
|
86
87
|
]],
|
|
87
88
|
["Tasks and review", [
|
|
88
89
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person or configured conductor: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
89
90
|
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--handoffs] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for; --handoffs: what this project handed to other projects and the last state each reported"],
|
|
90
|
-
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
91
|
+
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--needs-network] [--needs-worktree] [--backlog] [--detail <text>]", "put a task on the board; --needs-network keeps it away from a peer whose shell has none; --needs-worktree keeps it away from a sandboxed peer, which cannot create the task worktree; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
91
92
|
["ahub task handoff --project <root|id> [<class>] <title...> [--path <p>]... [--issue <n>] [--task <n>] [--detail <text>] [--json] [--color=auto|always|never]", "person only: register work that belongs to ANOTHER project that runs agent-hub on that project's board (an ownerless proposal its own roles take from there); the target must be registered and running, nothing is queued; a feature or behavior change first needs a spec issue there (--issue); its state changes show in ahub board --handoffs (an agent that hands off gets them as task messages)"],
|
|
92
93
|
["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision> [--json] [--color=auto|always|never]", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
|
|
93
94
|
["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision> [--json] [--color=auto|always|never]", "person only, except show (any agent, public metadata): planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -29,6 +29,8 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
|
|
|
29
29
|
|| (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet"
|
|
30
30
|
// #691: trusting an untested agent CLI version is the person's consent, never a delegated live-test operation.
|
|
31
31
|
|| command === "autonomy"
|
|
32
|
+
// #1064 T3: a stamp is a person's record that they reviewed a document; no delegation, live-test or otherwise, may write one.
|
|
33
|
+
|| (command === "specs" && args[0] === "stamp")
|
|
32
34
|
|| (command === "agents" && !args.every(arg => arg === "--json"));
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -77,8 +79,9 @@ interface PeerReadForm {
|
|
|
77
79
|
const publicReadDisplayFlags = ["--json", "--full", "--color=auto", "--color=always", "--color=never"] as const;
|
|
78
80
|
const sdlcReadDisplayFlags = ["--json", "--color=auto", "--color=always", "--color=never"] as const;
|
|
79
81
|
const peerReadForms: Readonly<Record<string, Readonly<Record<string, PeerReadForm>>>> = {
|
|
82
|
+
specs: { list: { displayFlags: ["--json"] }, check: { valueFlags: ["--base"], displayFlags: ["--json", "--require-fresh"] }, covers: { positional: 2000, minimum: 1, displayFlags: [] } },
|
|
80
83
|
room: { list: { displayFlags: ["--json"] }, status: { positional: 1, displayFlags: ["--json"] } },
|
|
81
|
-
queue: { list: { valueFlags: ["--peer"], displayFlags: publicReadDisplayFlags } },
|
|
84
|
+
queue: { list: { valueFlags: ["--peer"], displayFlags: [...publicReadDisplayFlags, "--all"] } },
|
|
82
85
|
roster: { "": { displayFlags: publicReadDisplayFlags } },
|
|
83
86
|
settings: { "": { displayFlags: ["--json"] }, list: { displayFlags: ["--json"] }, get: { positional: 1, minimum: 1, displayFlags: ["--json"] } },
|
|
84
87
|
// #814 renders these reads for people and keeps JSON as an explicit data format.
|
|
@@ -198,6 +201,11 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
|
|
|
198
201
|
// #601: a campaign id and a contract path are text the person chose, so the audit names the subcommand only.
|
|
199
202
|
if (command === "study") return ["draft", "revise", "approve", "close", "show", "experiments"].includes(args[0] ?? "") ? `study ${args[0]}` : "study";
|
|
200
203
|
if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
|
|
204
|
+
// #1084: `autonomy`, `deliverables` and `usage` were missing from the table below, so a refusal and its audit line named them "unknown". Operands (a flag value, a task id) never reach the label.
|
|
205
|
+
if (command === "autonomy") { const sub = args.find(arg => !arg.startsWith("--")); return sub !== undefined && ["on", "off", "status"].includes(sub) ? `autonomy ${sub}` : "autonomy"; }
|
|
206
|
+
if (command === "usage") return args[0] === "limits" || args[0] === "total" ? `usage ${args[0]}` : "usage";
|
|
207
|
+
if (command === "deliverables") return "deliverables";
|
|
208
|
+
if (command === "specs") return ["list", "check", "covers", "stamp"].includes(args[0] ?? "") ? `specs ${args[0]}` : "specs";
|
|
201
209
|
if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn", "study"].includes(command)) return "unknown";
|
|
202
210
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
|
|
203
211
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|