@staix/agent-hub 0.12.35 → 0.12.36
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 +14 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +4 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/tasks.md +11 -1
- package/docs/agent-notes/tests.md +5 -1
- package/docs/operations.md +40 -16
- package/docs/quickstart.md +6 -8
- package/docs/sdlc.md +46 -5
- package/docs/security.md +5 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +76 -1
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +18 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +385 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/registry-types.ts +8 -2
- package/src/adapters/registry.ts +13 -1
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +38 -4
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +29 -7
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +181 -73
- package/src/hub/hub-tools.ts +20 -1
- package/src/hub/instances.ts +17 -3
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/routing.ts +17 -2
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +544 -26
- package/src/hub/usage.ts +57 -20
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { childEnv, peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
4
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
5
|
+
import { changedPathGroups } from "../hub/review-brief.ts";
|
|
6
|
+
import { HUB, frame, type Envelope } from "../hub/envelope.ts";
|
|
7
|
+
import { BasePeer } from "../hub/peers.ts";
|
|
8
|
+
|
|
9
|
+
export interface ClaudePrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
10
|
+
|
|
11
|
+
export interface ClaudePrintOptions {
|
|
12
|
+
cwd: string;
|
|
13
|
+
/** The native argv prefix; `claude` from PATH by default. */
|
|
14
|
+
command?: readonly string[];
|
|
15
|
+
/** The agent-hub MCP server in tools mode for this instance, as `--mcp-config` JSON. */
|
|
16
|
+
mcpConfig: string;
|
|
17
|
+
systemPrompt: string;
|
|
18
|
+
/** pii.paths and local.deny entries, passed to Claude as Read deny rules. */
|
|
19
|
+
privatePaths?: readonly string[];
|
|
20
|
+
watchdogMs: number;
|
|
21
|
+
/** Facts the hub adds to one review request (a bounded diff of its head); never the builder's narrative. */
|
|
22
|
+
facts?(env: Envelope): string | undefined;
|
|
23
|
+
/** Whether this peer recorded a verdict on the task since `since` (ms). */
|
|
24
|
+
verdictSince(task: number, since: number): boolean;
|
|
25
|
+
onUsage?(usage: ClaudePrintUsage, task: number): void;
|
|
26
|
+
log?(line: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The flags that make each review a new, unpersisted session with read-only tools and only the verdict tools. */
|
|
30
|
+
export function claudePrintArgs(sessionId: string, mcpConfig: string, systemPrompt: string, privatePaths: readonly string[] = []): string[] {
|
|
31
|
+
return ["-p", "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--session-id", sessionId,
|
|
32
|
+
"--strict-mcp-config", "--mcp-config", mcpConfig, "--setting-sources", "",
|
|
33
|
+
"--tools", "Read,Grep,Glob", "--allowedTools", "mcp__agent-hub__hub_review", "mcp__agent-hub__hub_task_show", "Read", "Grep", "Glob",
|
|
34
|
+
...(privatePaths.length ? ["--disallowedTools", ...readDenyRules(privatePaths)] : []),
|
|
35
|
+
"--permission-mode", "dontAsk", "--append-system-prompt", systemPrompt];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Claude's own Read deny rules for the project's private paths (pii.paths, local.deny).
|
|
40
|
+
* ponytail: Claude evaluates these, not the hub's path policy (no symlink or case-alias resolution); the hub's own
|
|
41
|
+
* guarantee is that it never puts private content into the prompt. Run reviews of private-path work on-prem.
|
|
42
|
+
*/
|
|
43
|
+
export function readDenyRules(paths: readonly string[]): string[] {
|
|
44
|
+
return paths.flatMap(entry => {
|
|
45
|
+
const rule = entry.startsWith("/") ? `/${entry}` : `./${entry.replace(/^\.\//, "")}`;
|
|
46
|
+
return /[*?]/.test(entry) ? [`Read(${rule})`] : [`Read(${rule})`, `Read(${rule.replace(/\/$/, "")}/**)`];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DIFF_CAP = 100_000;
|
|
51
|
+
/**
|
|
52
|
+
* The review's head against its merge base with the project checkout's HEAD, bounded. Only a hex commit id is used.
|
|
53
|
+
* ponytail: a fixed byte cap; the reviewer reads the rest of the files with Read/Grep, add a per-file summary if reviews
|
|
54
|
+
* of large changes miss things.
|
|
55
|
+
*/
|
|
56
|
+
export function reviewDiff(cwd: string, commit: unknown, policy: { privatePath(path: string): boolean; nameable(text: string): boolean }): string | undefined {
|
|
57
|
+
if (typeof commit !== "string" || !/^[0-9a-f]{7,40}$/i.test(commit)) return undefined;
|
|
58
|
+
const git = (args: string[]) => hubGitSync(["-C", cwd, "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--relative", ...args], { encoding: "utf8", env: childEnv(process.env), maxBuffer: 16 * 1024 * 1024, timeout: 15_000 });
|
|
59
|
+
const unavailable = `Diff of ${commit} unavailable here; read the changed files with Read and Grep.`;
|
|
60
|
+
// Paths first, as groups (both sides of a move or copy): a group is withheld whole when any side is private.
|
|
61
|
+
const groups = changedPathGroups(cwd, commit);
|
|
62
|
+
if (!groups) return unavailable;
|
|
63
|
+
const changed = groups.map(g => g.paths);
|
|
64
|
+
const shownGroups = changed.filter(group => !group.some(path => policy.privatePath(path)));
|
|
65
|
+
const shown = shownGroups.flat();
|
|
66
|
+
const hidden = changed.length - shownGroups.length;
|
|
67
|
+
const note = hidden ? `\n[${hidden} changed file(s) withheld by the project's privacy policy]` : "";
|
|
68
|
+
if (!shown.length) return `Diff of ${commit}: no changed file can be shown.${note}`;
|
|
69
|
+
const r = git(["-M", "--stat", "--patch", `HEAD...${commit}`, "--", ...shown.map(path => `:(literal)${path}`)]);
|
|
70
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return unavailable;
|
|
71
|
+
if (!policy.nameable(r.stdout)) return `Diff of ${commit} withheld: it matches a private-data pattern. Read the changed files with Read and Grep.${note}`;
|
|
72
|
+
const bytes = Buffer.from(r.stdout, "utf8");
|
|
73
|
+
const body = bytes.length > DIFF_CAP ? `${bytes.subarray(0, DIFF_CAP).toString("utf8")}\n[diff cut at ${DIFF_CAP} of ${bytes.length} bytes; read the rest with Read and Grep]` : r.stdout;
|
|
74
|
+
return `Diff of ${commit} against its merge base with the checkout's HEAD:\n${body}${note}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The review's leader exited but its process group could not be confirmed gone; the child handle is kept. */
|
|
78
|
+
class GroupCleanupError extends Error {}
|
|
79
|
+
|
|
80
|
+
const reviewTask = (env: Envelope): number | undefined => {
|
|
81
|
+
if (env.from !== HUB || env.kind !== "review") return undefined;
|
|
82
|
+
const id = Number(env.refs?.task);
|
|
83
|
+
return Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* #223 R3: a hub-native Claude reviewer. Idle with no process; each hub review request runs one `claude -p` child in a
|
|
88
|
+
* new session (never resumed), so no verdict sees an earlier round or builder chat. Its final text is not published:
|
|
89
|
+
* the verdict is the child's own `hub_review` call.
|
|
90
|
+
*/
|
|
91
|
+
export class ClaudePrintPeer extends BasePeer {
|
|
92
|
+
readonly hubNative = true;
|
|
93
|
+
private generation = 0;
|
|
94
|
+
private child: ChildProcess | undefined;
|
|
95
|
+
private activeDeliveryId: string | undefined;
|
|
96
|
+
/** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
|
|
97
|
+
reviewing: number | undefined;
|
|
98
|
+
private reviewSeq = 0;
|
|
99
|
+
/** Identifies the running child's authority over `task`; a replacement child for the same task gets a new token. */
|
|
100
|
+
reviewToken(task: number): number | undefined { return this.reviewing === task ? this.reviewSeq : undefined; }
|
|
101
|
+
|
|
102
|
+
constructor(id: string, private readonly opts: ClaudePrintOptions) { super(id, opts.watchdogMs); }
|
|
103
|
+
|
|
104
|
+
recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "claude-print", peer: this.id } }; }
|
|
105
|
+
|
|
106
|
+
async start(): Promise<void> {
|
|
107
|
+
// A retained child means a review's process group was never confirmed gone: no new review beside it.
|
|
108
|
+
if (this.child) throw new Error(`${this.id}: an earlier review's process group is still running; stop it first`);
|
|
109
|
+
this.setState("idle");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async stop(reason?: string): Promise<void> {
|
|
113
|
+
this.generation++; // fence before the first await: a late exit settles nothing
|
|
114
|
+
this.settle("needs_review", reason ?? "review stopped before settlement");
|
|
115
|
+
if (this.child) this.setState("busy"); // unavailable, not offline, until the group is confirmed gone
|
|
116
|
+
await this.kill(); // throws when the child could not be stopped; its handle is kept and the peer stays busy
|
|
117
|
+
this.setState("offline");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
protected override onWatchdog(): void {
|
|
121
|
+
this.generation++;
|
|
122
|
+
this.settle("needs_review", "review watchdog timeout");
|
|
123
|
+
// Busy until the old child is gone: a new review never runs beside it.
|
|
124
|
+
void this.kill().then(() => { if (this.state === "busy") super.onWatchdog(); }, (error: Error) => this.opts.log?.(`[${this.id}] watchdog stop failed, staying busy: ${error.message}`));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
128
|
+
if (this.state !== "idle") {
|
|
129
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
|
|
130
|
+
throw new Error(`${this.id} is ${this.state}`);
|
|
131
|
+
}
|
|
132
|
+
const generation = ++this.generation;
|
|
133
|
+
this.activeDeliveryId = deliveryId;
|
|
134
|
+
this.setState("busy");
|
|
135
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
|
|
136
|
+
void this.run(envs, generation).catch((error: Error) => {
|
|
137
|
+
if (generation !== this.generation) return;
|
|
138
|
+
this.settle("needs_review", `claude reviewer failed: ${error.message}`);
|
|
139
|
+
void this.kill().then(() => this.setState("idle"), () => undefined);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async run(envs: Envelope[], generation: number): Promise<void> {
|
|
144
|
+
const missing: number[] = [];
|
|
145
|
+
let started = false;
|
|
146
|
+
let failure: { state: "failed_safe" | "needs_review"; reason: string } | undefined;
|
|
147
|
+
for (const env of envs) {
|
|
148
|
+
const task = reviewTask(env);
|
|
149
|
+
if (task === undefined) continue; // a withdrawal or other notice: nothing to review
|
|
150
|
+
if (env.private) { failure = { state: "needs_review", reason: "a private review is the person's, never a cloud reviewer's" }; break; }
|
|
151
|
+
const since = Date.now();
|
|
152
|
+
let code: number | null;
|
|
153
|
+
try { code = await this.review(env, task, generation); }
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (generation !== this.generation) return;
|
|
156
|
+
if (error instanceof GroupCleanupError) {
|
|
157
|
+
// Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
|
|
158
|
+
this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
|
|
159
|
+
this.settle("needs_review", `review process group still running: ${error.message}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
|
|
163
|
+
failure = { state: started ? "needs_review" : "failed_safe", reason: `claude reviewer could not start: ${(error as Error).message}` }; break;
|
|
164
|
+
}
|
|
165
|
+
started = true;
|
|
166
|
+
if (generation !== this.generation) return;
|
|
167
|
+
let recorded = false;
|
|
168
|
+
try { recorded = this.opts.verdictSince(task, since); } catch (error) { this.opts.log?.(`[${this.id}] verdict check for task #${task} failed: ${(error as Error).message}`); }
|
|
169
|
+
if (!recorded) missing.push(task);
|
|
170
|
+
if (code !== 0) this.opts.log?.(`[${this.id}] review of task #${task} exited ${code}`);
|
|
171
|
+
}
|
|
172
|
+
if (generation !== this.generation) return;
|
|
173
|
+
this.settle(failure?.state ?? (missing.length ? "needs_review" : "completed"), failure?.reason ?? (missing.length ? `no verdict recorded for task ${missing.map(id => `#${id}`).join(", ")}` : undefined));
|
|
174
|
+
this.setState("idle");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private review(env: Envelope, task: number, generation: number): Promise<number | null> {
|
|
178
|
+
const [cmd = "claude", ...prefix] = this.opts.command ?? ["claude"];
|
|
179
|
+
const args = [...prefix, ...claudePrintArgs(randomUUID(), this.opts.mcpConfig, this.opts.systemPrompt, this.opts.privatePaths)];
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const child = spawn(cmd, args, { cwd: this.opts.cwd, detached: true, env: peerChildEnv(this.id), stdio: ["pipe", "pipe", "ignore"] });
|
|
182
|
+
// Owned before any await, so a stop that lands before "spawn" still finds and kills it.
|
|
183
|
+
this.child = child;
|
|
184
|
+
child.once("error", error => { if (this.child === child) this.child = undefined; reject(error); });
|
|
185
|
+
child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
|
|
186
|
+
child.once("spawn", () => {
|
|
187
|
+
trackGroup(child);
|
|
188
|
+
if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
|
|
189
|
+
this.reviewing = task; this.reviewSeq++;
|
|
190
|
+
let facts: string | undefined;
|
|
191
|
+
try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
|
|
192
|
+
child.stdin!.end(`${frame(env)}${facts ? `\n\n${facts}` : ""}`);
|
|
193
|
+
});
|
|
194
|
+
let buffer = "";
|
|
195
|
+
child.stdout!.on("data", (chunk: Buffer) => {
|
|
196
|
+
if (generation !== this.generation) return;
|
|
197
|
+
this.touch();
|
|
198
|
+
buffer += chunk.toString("utf8");
|
|
199
|
+
for (let i; (i = buffer.indexOf("\n")) >= 0;) {
|
|
200
|
+
const line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
|
|
201
|
+
try {
|
|
202
|
+
const event = JSON.parse(line) as { type?: string; usage?: ClaudePrintUsage };
|
|
203
|
+
if (event.type === "result" && event.usage) this.opts.onUsage?.(event.usage, task);
|
|
204
|
+
} catch { /* not a stream-json line */ }
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
child.once("exit", code => {
|
|
208
|
+
if (this.child === child) this.reviewing = undefined; // the verdict authority ends with the leader
|
|
209
|
+
// The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
|
|
210
|
+
stopOwnedProcess(child, { group: true }).then(() => { if (this.child === child) this.child = undefined; resolve(code); },
|
|
211
|
+
(error: Error) => reject(new GroupCleanupError(error.message)));
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
|
|
217
|
+
const id = this.activeDeliveryId;
|
|
218
|
+
this.activeDeliveryId = undefined;
|
|
219
|
+
if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
|
|
223
|
+
private async kill(): Promise<void> {
|
|
224
|
+
this.reviewing = undefined;
|
|
225
|
+
const child = this.child;
|
|
226
|
+
if (!child) return;
|
|
227
|
+
await stopOwnedProcess(child, { group: true });
|
|
228
|
+
if (this.child === child) this.child = undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -6,7 +6,7 @@ import { piRecoveryContinuity } from "./native-recovery-continuity.ts";
|
|
|
6
6
|
import { piConductorStartArgs } from "./native-cli-pi.ts";
|
|
7
7
|
import { claudeNativeLaunch, CLAUDE_LAUNCH_POLICY } from "./native-cli-claude-launch.ts";
|
|
8
8
|
import { codexNativeLaunch, CODEX_LAUNCH_POLICY } from "./native-cli-codex-launch.ts";
|
|
9
|
-
import { createCodex, createKimi, createJcode, createPi, createLocal } from "./native-factories.ts";
|
|
9
|
+
import { createCodex, createKimi, createJcode, createPi, createLocal, createClaudeReviewer } from "./native-factories.ts";
|
|
10
10
|
import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY, JCODE_DISPLAY } from "./native-display.ts";
|
|
11
11
|
import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
|
|
12
12
|
import { claudeWindows, codexWindows } from "./native-quota.ts";
|
|
@@ -54,6 +54,21 @@ const hubGrants = {
|
|
|
54
54
|
"never-ask": "every tool runs without asking, inside the hub sandbox and path guard",
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* #223: a declared `claude-*` instance with `start_mode: "headless"` and roles exactly ["reviewer"]. The hub runs one
|
|
59
|
+
* fresh `claude -p` session per review request (src/adapters/claude-print.ts); it never owns a terminal or a session.
|
|
60
|
+
*/
|
|
61
|
+
const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
|
|
62
|
+
api: 1, kind: "claude", displayName: "Claude Code reviewer", family: "claude", transport: "hub-native",
|
|
63
|
+
roles: Object.freeze(["reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
64
|
+
start: { create: createClaudeReviewer, daemonPort: "claude-print", forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("claude", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
65
|
+
identity: { mcp: "env" },
|
|
66
|
+
permission: { modes: {}, unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
|
|
67
|
+
session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "claude-print", crashPlan: () => ({ resume: {}, how: "claude reviewer: starts again; every review runs in a new session" }) },
|
|
68
|
+
turnEnd: "adapter", budget: { account: "claude" }, routing: { reviewOnly: true },
|
|
69
|
+
cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
|
|
70
|
+
};
|
|
71
|
+
|
|
57
72
|
// Sources: peer-catalog.ts roles, roster.ts defaults, start-mode.ts forms, permission-mode.ts grants,
|
|
58
73
|
// daemon.ts runtime mappings/stop policy, budget.ts exemptions, and ui/peer-palette.ts CSS foregrounds.
|
|
59
74
|
export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
@@ -70,6 +85,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
70
85
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
|
|
71
86
|
turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
72
87
|
cli: { command: "claude", retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
|
|
88
|
+
reviewerVariant: CLAUDE_REVIEWER_VARIANT,
|
|
73
89
|
},
|
|
74
90
|
{
|
|
75
91
|
api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
@@ -3,9 +3,22 @@ import { join } from "node:path";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import type { ClaudeLaunchRecord } from "../../cli/launch.ts";
|
|
5
5
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
6
|
+
import { claudeStateFile } from "../../hub/claude-state.ts";
|
|
6
7
|
|
|
7
8
|
export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
8
9
|
const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, fail } = ctx;
|
|
10
|
+
// #450 I3: a claude-<name> instance keeps its own identity, launch and recovery records; it never owns a worktree.
|
|
11
|
+
const peer = ctx.instance ?? "claude";
|
|
12
|
+
if (ctx.hubStarted) {
|
|
13
|
+
// #223: the hub starts a headless reviewer and runs one `claude -p` per review; nothing runs in this terminal.
|
|
14
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
|
|
15
|
+
const hub = await ctx.connect();
|
|
16
|
+
try {
|
|
17
|
+
const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
18
|
+
if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
|
|
19
|
+
} finally { hub.close(); }
|
|
20
|
+
return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
|
|
21
|
+
}
|
|
9
22
|
const { launcherPreview, readControl, recordTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
|
|
10
23
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
11
24
|
try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
@@ -14,7 +27,7 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
|
|
|
14
27
|
const control = readControl(stateDir);
|
|
15
28
|
if (control?.instanceId) {
|
|
16
29
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
17
|
-
const terminal = await recordTerminalLaunch(
|
|
30
|
+
const terminal = await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: cwd } : undefined);
|
|
18
31
|
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
19
32
|
}
|
|
20
33
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
@@ -30,19 +43,20 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
|
|
|
30
43
|
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
31
44
|
const facts = claudeObservationHooks(projectConfig(), { script: join(cliDir, "facts-hook.ts"), stateDir });
|
|
32
45
|
let previousLaunch: unknown;
|
|
33
|
-
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch
|
|
46
|
+
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", peer)), "utf8")); } catch { /* no previous managed launch */ }
|
|
34
47
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(cliDir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
35
48
|
let result: Awaited<ReturnType<typeof runClaudeLaunch>>;
|
|
36
49
|
try {
|
|
37
50
|
let launchRecord: ClaudeLaunchRecord | undefined;
|
|
38
51
|
if (control?.instanceId) {
|
|
39
52
|
// Publish only the final launch's installed hook and effective permission flags.
|
|
40
|
-
launchRecord = { instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
|
|
53
|
+
launchRecord = { ...(ctx.instance ? { peer } : {}), instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
|
|
41
54
|
recordClaudeLaunch(stateDir, launchRecord);
|
|
42
55
|
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
43
56
|
}
|
|
44
57
|
if (launch.warning) console.error(launch.warning);
|
|
45
58
|
const env = nativeLaunchEnv("claude", childEnv());
|
|
59
|
+
if (ctx.instance) env.AGENTHUB_PEER_ID = peer; // the channel plugin and the statusline tee write for this instance
|
|
46
60
|
result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
|
|
47
61
|
} finally {
|
|
48
62
|
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
3
|
-
import { rosterInStatus } from "../../hub/status-roster.ts";
|
|
3
|
+
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
4
4
|
|
|
5
5
|
export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
6
6
|
const { args, cwd, stateDir, cliDir, unattendedEnv, connect, fail, takeFlags } = ctx;
|
|
@@ -18,7 +18,8 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
18
18
|
const rostered = rosterInStatus(snapshot, "jcode");
|
|
19
19
|
if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
|
|
20
20
|
if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
21
|
-
|
|
21
|
+
// The daemon verifies the entry against its own bundle; fall back to this CLI's copy only for a hub that reports none.
|
|
22
|
+
const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
|
|
22
23
|
if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
|
|
23
24
|
const reply = await hub.request({ t: "start", peer: "jcode", args: { mode: "headless", model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
|
|
24
25
|
if (!reply.ok) fail(reply.error);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
4
|
-
import { rosterInStatus } from "../../hub/status-roster.ts";
|
|
4
|
+
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
5
5
|
|
|
6
6
|
export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
7
7
|
const peer = ctx.instance ?? "kimi";
|
|
@@ -38,7 +38,8 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
38
38
|
if (!admission.ok) fail(admission.error);
|
|
39
39
|
let result: ReturnType<typeof spawnSync>;
|
|
40
40
|
try {
|
|
41
|
-
|
|
41
|
+
// The daemon's own bundle, so the MCP server matches the hub it talks to (#502); this CLI's copy only for an older hub.
|
|
42
|
+
const written = configureKimiMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"), "tui");
|
|
42
43
|
if (written.tracked) console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
|
|
43
44
|
result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...nativeLaunchEnv("kimi", childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
44
45
|
} finally {
|
|
@@ -5,6 +5,8 @@ import type { AdapterDefinition } from "../registry-types.ts";
|
|
|
5
5
|
/** Private static builtin relocation ports. The CLI binds identity, project and authority before invocation. */
|
|
6
6
|
export interface NativeCliEntryContext {
|
|
7
7
|
instance?: string; executionCwd?: string;
|
|
8
|
+
/** #223: the selected instance is started by the hub (a headless reviewer), never as a terminal launch. */
|
|
9
|
+
hubStarted?: true;
|
|
8
10
|
args: string[]; cwd: string; stateDir: string; cliDir: string; unattendedEnv: boolean; definition: AdapterDefinition;
|
|
9
11
|
projectConfig(): HubConfig;
|
|
10
12
|
connect(): Promise<ControlClient>;
|
|
@@ -38,6 +38,12 @@ export function createLocal(ctx: CreateContext): PeerAdapter {
|
|
|
38
38
|
return new LocalPeer(ctx.instance, ctx.daemon.local);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function createClaudeReviewer(ctx: CreateContext): PeerAdapter {
|
|
42
|
+
if (!("claudePrint" in ctx.daemon)) throw new Error("Claude reviewer requires its print transport options");
|
|
43
|
+
const { ClaudePrintPeer } = require("../claude-print.ts") as typeof import("../claude-print.ts");
|
|
44
|
+
return new ClaudePrintPeer(ctx.instance, ctx.daemon.claudePrint);
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
/** Preserve existing transport option types and object identity; selection stays host-owned. */
|
|
42
48
|
export const DAEMON_PORTS = {
|
|
43
49
|
"codex-appserver": (options: Extract<DaemonPorts, { codex: unknown }>["codex"]) => ({ codex: options }),
|
|
@@ -45,4 +51,5 @@ export const DAEMON_PORTS = {
|
|
|
45
51
|
"owned-acp": (options: Extract<DaemonPorts, { jcode: unknown }>["jcode"]) => ({ jcode: options }),
|
|
46
52
|
"pi-rpc": (options: Extract<DaemonPorts, { pi: unknown }>["pi"]) => ({ pi: options }),
|
|
47
53
|
"local-worker": (options: Extract<DaemonPorts, { local: unknown }>["local"]) => ({ local: options }),
|
|
54
|
+
"claude-print": (options: Extract<DaemonPorts, { claudePrint: unknown }>["claudePrint"]) => ({ claudePrint: options }),
|
|
48
55
|
} satisfies Record<DaemonEngine, unknown>;
|
|
@@ -13,9 +13,10 @@ import type { CodexOptions } from "./codex-appserver.ts";
|
|
|
13
13
|
import type { JcodeOptions } from "./jcode.ts";
|
|
14
14
|
import type { LocalOptions } from "./local-worker.ts";
|
|
15
15
|
import type { PiOptions } from "./pi.ts";
|
|
16
|
+
import type { ClaudePrintOptions } from "./claude-print.ts";
|
|
16
17
|
|
|
17
18
|
export type StartForm = "tui" | "headless";
|
|
18
|
-
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker";
|
|
19
|
+
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print";
|
|
19
20
|
|
|
20
21
|
/** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
|
|
21
22
|
export interface LaunchContext {
|
|
@@ -53,7 +54,8 @@ export type DaemonPorts =
|
|
|
53
54
|
| { acp: AcpOptions }
|
|
54
55
|
| { jcode: JcodeOptions }
|
|
55
56
|
| { pi: PiOptions }
|
|
56
|
-
| { local: LocalOptions }
|
|
57
|
+
| { local: LocalOptions }
|
|
58
|
+
| { claudePrint: ClaudePrintOptions };
|
|
57
59
|
|
|
58
60
|
export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
|
|
59
61
|
instance: string;
|
|
@@ -223,6 +225,8 @@ export interface AdapterDefinition {
|
|
|
223
225
|
reporting?: { tokensNotReported?: boolean };
|
|
224
226
|
/** Legacy native routing policy; external definitions cannot claim these host-specific policies. */
|
|
225
227
|
routing?: {
|
|
228
|
+
/** Takes reviews only: never chosen or nominated as a task owner, for any class. */
|
|
229
|
+
reviewOnly?: true;
|
|
226
230
|
localAllowedRefusal?: string;
|
|
227
231
|
longContext?: "backend-limit" | "skip-owner";
|
|
228
232
|
preferredTier?: boolean;
|
|
@@ -258,6 +262,8 @@ export interface AdapterDefinition {
|
|
|
258
262
|
};
|
|
259
263
|
display: { palette: { fg: string; bg: string; tone?: string; ansi?: string } };
|
|
260
264
|
hooks?: Partial<NativeHooks>;
|
|
265
|
+
/** #223: what a declared headless reviewer-only instance of this kind runs as. Fixed builtins only. */
|
|
266
|
+
reviewerVariant?: AdapterDefinition;
|
|
261
267
|
}
|
|
262
268
|
|
|
263
269
|
/** Explicit project declarations, passed by the bound host. No process-global instance registration. */
|
package/src/adapters/registry.ts
CHANGED
|
@@ -26,6 +26,7 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION]);
|
|
29
|
+
for (const definition of [...fixedBuiltinDefinitions]) if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
|
|
29
30
|
|
|
30
31
|
/** Keep the native catalog's order, independently of the legacy default-role object order. */
|
|
31
32
|
export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
@@ -37,6 +38,7 @@ const definitions = new Map<string, AdapterDefinition>();
|
|
|
37
38
|
for (const definition of ADAPTERS) {
|
|
38
39
|
assertDaemonEngine(definition);
|
|
39
40
|
assertNativeCapabilityScope(definition);
|
|
41
|
+
if (definition.reviewerVariant) { assertDaemonEngine(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
|
|
40
42
|
if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
|
|
41
43
|
definitions.set(definition.kind, definition);
|
|
42
44
|
}
|
|
@@ -46,6 +48,13 @@ export const QUOTA_HANDOFF_PLATFORMS: Readonly<Record<string, string>> = Object.
|
|
|
46
48
|
ADAPTERS.flatMap(adapter => adapter.hooks?.recallPlatform ? [[adapter.kind, adapter.hooks.recallPlatform]] : []),
|
|
47
49
|
));
|
|
48
50
|
|
|
51
|
+
/** Own enumerable data field of a plain object, never a caller accessor or an inherited value. */
|
|
52
|
+
function ownField(value: unknown, key: string): unknown {
|
|
53
|
+
if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)
|
|
54
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return undefined;
|
|
55
|
+
const field = Object.getOwnPropertyDescriptor(value, key);
|
|
56
|
+
return field && Object.hasOwn(field, "value") && field.enumerable ? field.value : undefined;
|
|
57
|
+
}
|
|
49
58
|
/** Declaration reads never execute caller accessors or inherit another project's mapping. */
|
|
50
59
|
function declaredKind(id: string, instances: KindMap | undefined): string | undefined {
|
|
51
60
|
if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
|
|
@@ -66,7 +75,9 @@ export function kindOf(id: string, instances?: KindMap): string | undefined {
|
|
|
66
75
|
}
|
|
67
76
|
export function definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined {
|
|
68
77
|
const kind = kindOf(id, instances);
|
|
69
|
-
|
|
78
|
+
const definition = kind === undefined ? undefined : definitions.get(kind);
|
|
79
|
+
// #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
|
|
80
|
+
return definition?.reviewerVariant && kind !== id && ownField(ownField(instances, id), "start_mode") === "headless" ? definition.reviewerVariant : definition;
|
|
70
81
|
}
|
|
71
82
|
export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
72
83
|
return definitionOf(id, instances)?.family;
|
|
@@ -113,6 +124,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
113
124
|
["session.crashIdentity", declared(definition.session, "crashIdentity")],
|
|
114
125
|
["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
|
|
115
126
|
["routing", definition.routing !== undefined],
|
|
127
|
+
["reviewerVariant", definition.reviewerVariant !== undefined],
|
|
116
128
|
];
|
|
117
129
|
const field = restricted.find(([, present]) => present)?.[0];
|
|
118
130
|
if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
|
package/src/cli/help.ts
CHANGED
|
@@ -23,6 +23,10 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
23
23
|
["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
|
|
24
24
|
["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
|
|
25
25
|
["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
|
|
26
|
+
["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
|
|
27
|
+
["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N --ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
|
|
28
|
+
["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
|
|
29
|
+
["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
|
|
26
30
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
27
31
|
["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
|
|
28
32
|
["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
|
|
@@ -84,6 +88,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
84
88
|
["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
|
|
85
89
|
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
86
90
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
|
|
91
|
+
["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
|
|
87
92
|
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
|
88
93
|
["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
|
|
89
94
|
["ahub ask [--remember] <question...>", "answer from the task board, shared memory and this run's log, with the ids it rests on"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -13,13 +13,14 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
|
|
|
13
13
|
|
|
14
14
|
/** Person verdicts and user-authored workflow content are never delegated. */
|
|
15
15
|
export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
|
|
16
|
+
if (command === "sdlc" && args[0] === "plan-review") return true;
|
|
16
17
|
if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
|
|
17
18
|
const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
|
|
18
19
|
return ["task", "role", "goal"].includes(command)
|
|
19
20
|
|| (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
|
|
20
21
|
|| (command === "memory" && sub !== "graph")
|
|
21
22
|
|| (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
|
|
22
|
-
|| (command === "board" && args[0] === "import") || (command === "sdlc" && args[0] === "record")
|
|
23
|
+
|| (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept"))
|
|
23
24
|
|| (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
|
|
24
25
|
}
|
|
25
26
|
|
package/src/cli/launch.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
|
+
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
2
3
|
import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
import { randomUUID, createHash } from "node:crypto";
|
|
@@ -12,10 +13,10 @@ import { kimiAcpCommand } from "../adapters/definitions/native-daemon-kimi.ts";
|
|
|
12
13
|
export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
|
|
13
14
|
|
|
14
15
|
/** Consume only ahub's selector, never native arguments after the passthrough delimiter. */
|
|
15
|
-
export function instanceLaunchArguments(command: string, raw: readonly string[], instances?: InstancesConfig): { instance?: string; args: string[] } {
|
|
16
|
+
export function instanceLaunchArguments(command: string, raw: readonly string[], instances?: InstancesConfig): { instance?: string; args: string[]; hubStarted?: true } {
|
|
16
17
|
if (!definitionOf(command)) return { args: [...raw] };
|
|
17
18
|
const args: string[] = [];
|
|
18
|
-
let instance: string | undefined;
|
|
19
|
+
let instance: string | undefined, hubStarted = false;
|
|
19
20
|
for (let index = 0; index < raw.length; index++) {
|
|
20
21
|
const arg = raw[index]!;
|
|
21
22
|
if (arg === "--") { args.push(...raw.slice(index)); break; }
|
|
@@ -25,9 +26,19 @@ export function instanceLaunchArguments(command: string, raw: readonly string[],
|
|
|
25
26
|
if (!id || id.startsWith("--")) throw new Error("--as needs a declared instance id");
|
|
26
27
|
const declaration = instances && Object.hasOwn(instances, id) ? instances[id] : undefined;
|
|
27
28
|
if (!declaration || declaration.kind !== command || !definitionOf(id, instances)) throw new Error("--as must name a declared instance of this command's native kind");
|
|
29
|
+
// #223: a headless reviewer is started by the hub, never from this terminal.
|
|
30
|
+
hubStarted = definitionOf(id, instances) === definitionOf(command)?.reviewerVariant;
|
|
28
31
|
instance = id;
|
|
29
32
|
}
|
|
30
|
-
|
|
33
|
+
if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run")) throw new Error(`${instance} is a headless reviewer the hub runs per review: ahub ${command} --as ${instance} takes no launch arguments`);
|
|
34
|
+
return { ...(instance ? { instance } : {}), args, ...(hubStarted ? { hubStarted: true as const } : {}) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The running daemon bound this instance at startup; a declaration added later, or an older daemon, is not proof. */
|
|
38
|
+
export function requireActiveInstance(status: unknown, instance: string): void {
|
|
39
|
+
const active = (status as { activeInstances?: unknown } | undefined)?.activeInstances;
|
|
40
|
+
if (!Array.isArray(active)) throw new Error("running hub does not report its declared instances; upgrade it before starting an extra instance");
|
|
41
|
+
if (!active.includes(instance)) throw new Error(`running hub has not loaded the declaration of ${instance}; restart the hub (ahub restart) after declaring it`);
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
/** Only an authenticated daemon hello can authorize extra-instance effects. */
|
|
@@ -92,6 +103,8 @@ export function cleanupStaleClaudeSettings(stateDir: string, previous: unknown,
|
|
|
92
103
|
|
|
93
104
|
export interface ClaudeLaunchRecord extends Record<string, unknown> {
|
|
94
105
|
instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
|
|
106
|
+
/** A claude-<name> instance (#450 I3); absent for the builtin claude peer, whose file keeps its original name. */
|
|
107
|
+
peer?: string;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
|
|
@@ -144,7 +157,7 @@ async function withClaudeLaunchRecord(stateDir: string, action: () => void): Pro
|
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
|
|
147
|
-
const file = join(stateDir, "claude-launch.
|
|
160
|
+
const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : "claude")), temp = `${file}.${randomUUID()}.tmp`;
|
|
148
161
|
try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
|
|
149
162
|
finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
|
|
150
163
|
}
|
|
@@ -187,7 +200,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
|
|
|
187
200
|
const publication = await withClaudeLaunchRecord(stateDir, () => {
|
|
188
201
|
let current: ClaudeLaunchRecord;
|
|
189
202
|
try {
|
|
190
|
-
const file = join(stateDir, "claude-launch.
|
|
203
|
+
const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), stat = lstatSync(file);
|
|
191
204
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
192
205
|
current = JSON.parse(readFileSync(file, "utf8"));
|
|
193
206
|
} catch { return; }
|
|
@@ -204,7 +217,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
|
|
|
204
217
|
writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
|
|
205
218
|
renameSync(temp, file);
|
|
206
219
|
} finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
|
|
207
|
-
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, "claude-launch.
|
|
220
|
+
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? "claude")), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
|
|
208
221
|
});
|
|
209
222
|
if (publication.warning) report?.(publication.warning);
|
|
210
223
|
return publication.recorded && recorded;
|