@staix/agent-hub 0.12.16 → 0.12.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +2 -0
  5. package/docs/agent-notes/tasks.md +3 -1
  6. package/docs/agent-notes/tests.md +1 -0
  7. package/docs/operations.md +106 -9
  8. package/docs/quickstart.md +3 -3
  9. package/docs/security.md +24 -0
  10. package/docs/smoke.md +93 -0
  11. package/docs/specs/2026-09-19-agent-hub-design.md +120 -1
  12. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  13. package/docs/verified.json +26 -17
  14. package/package.json +1 -1
  15. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  16. package/plugins/agent-hub/server.js +17 -5
  17. package/src/adapters/acp.ts +2 -2
  18. package/src/adapters/claude-channel.ts +3 -2
  19. package/src/adapters/codex-appserver.ts +3 -2
  20. package/src/adapters/local-worker.ts +5 -5
  21. package/src/cli/console-state.ts +213 -0
  22. package/src/cli/console.ts +201 -0
  23. package/src/cli/facts-hook.ts +8 -1
  24. package/src/cli/identity-audit.ts +66 -0
  25. package/src/cli/identity.ts +54 -0
  26. package/src/cli/launch.ts +15 -2
  27. package/src/cli/main.ts +62 -29
  28. package/src/cli/tail-render.ts +17 -0
  29. package/src/cli/upgrade-runtime.ts +1 -1
  30. package/src/hub/board.ts +6 -2
  31. package/src/hub/bus.ts +82 -10
  32. package/src/hub/child-process.ts +11 -0
  33. package/src/hub/conductor.ts +196 -0
  34. package/src/hub/control-client.ts +3 -3
  35. package/src/hub/daemon.ts +367 -46
  36. package/src/hub/envelope.ts +1 -1
  37. package/src/hub/events.ts +6 -1
  38. package/src/hub/hub-tools.ts +13 -0
  39. package/src/hub/report.ts +53 -4
  40. package/src/hub/supervision.ts +152 -0
  41. package/src/hub/tasks.ts +30 -15
  42. package/src/hub/usage.ts +37 -2
  43. package/src/pi/launch.ts +2 -1
@@ -0,0 +1,213 @@
1
+ import { sanitize } from "../hub/envelope.ts";
2
+
3
+ export interface Approval {
4
+ id: string; peer: string; title: string; expiresAt: number;
5
+ options: { optionId: string; name: string; kind: string }[];
6
+ }
7
+ export interface ConsoleEvent { text: string; peer?: string; kind?: string }
8
+ export interface ConsoleState {
9
+ mode: "stream" | "panels"; panel: number; selection: number; approvalIndex: number;
10
+ input: string; editing: boolean; history: string[]; historyIndex: number;
11
+ approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
12
+ tasks: any[]; queue: any[]; detail?: string; detailOffset: number; help: boolean; notice: string;
13
+ peerFilter?: string; kindFilter?: string;
14
+ confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] };
15
+ optionChoice?: string;
16
+ }
17
+ export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
18
+ { type: "command"; args: string[] } | { type: "show"; panel: number; id: string };
19
+ export function initialConsoleState(panels = false): ConsoleState {
20
+ return { mode: panels ? "panels" : "stream", panel: 1, selection: 0, approvalIndex: 0,
21
+ input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], queue: [], detailOffset: 0, help: false, notice: "" };
22
+ }
23
+ /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
24
+ export function terminalText(value: unknown): string {
25
+ return sanitize(String(value ?? ""))
26
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "")
27
+ .replace(/\x1b[P_X^][\s\S]*?(?:\x1b\\|$)/g, "")
28
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
29
+ .replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
30
+ }
31
+ export function fit(value: unknown, columns: number): string {
32
+ const text = terminalText(value).replace(/\n/g, " ");
33
+ if (columns <= 0) return "";
34
+ if (Bun.stringWidth(text) <= columns) return text;
35
+ let result = ""; const marker = ".".repeat(Math.min(3, columns));
36
+ for (const char of text) { if (Bun.stringWidth(result + char) > columns - marker.length) break; result += char; }
37
+ return result + marker;
38
+ }
39
+ export function wrap(value: unknown, columns: number): string[] {
40
+ const lines: string[] = [];
41
+ for (const part of terminalText(value).split("\n")) {
42
+ let line = "";
43
+ for (const char of part) {
44
+ if (Bun.stringWidth(line + char) > columns) { lines.push(line); line = ""; }
45
+ line += char;
46
+ }
47
+ lines.push(line);
48
+ }
49
+ return lines;
50
+ }
51
+ export function pruneApprovals(s: ConsoleState, now: number): ConsoleState {
52
+ const approvals = s.approvals.filter(a => a.expiresAt > now);
53
+ const live = (id: string) => approvals.some(a => a.id === id);
54
+ return { ...s, approvals, approvalIndex: Math.min(s.approvalIndex, Math.max(0, approvals.length - 1)),
55
+ ...(s.confirm?.type === "permission" && !live(s.confirm.id) ? { confirm: undefined } : {}),
56
+ ...(s.optionChoice && !live(s.optionChoice) ? { optionChoice: undefined } : {}) };
57
+ }
58
+ const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
59
+ /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
60
+ export function parseConsoleCommand(input: string): string[] {
61
+ const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
62
+ for (const char of input.trim()) {
63
+ if (escaped) { word += char; escaped = false; started = true; }
64
+ else if (char === "\\") { escaped = true; started = true; }
65
+ else if (quote) { if (char === quote) quote = ""; else word += char; }
66
+ else if (char === '"' || char === "'") { quote = char; started = true; }
67
+ else if (/\s/.test(char)) { if (started) { words.push(word); word = ""; started = false; } }
68
+ else { word += char; started = true; }
69
+ }
70
+ if (quote || escaped) throw new Error("unfinished quote or escape");
71
+ if (started) words.push(word);
72
+ if (!COMMANDS.has(words[0] ?? "")) throw new Error("use another shell for this command");
73
+ if (words.some(w => w === "--project" || w.startsWith("--project="))) throw new Error("console commands stay in the selected project");
74
+ return words;
75
+ }
76
+ export function commandNeedsConfirmation(args: string[]): boolean {
77
+ return (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
78
+ }
79
+ export function panelRows(s: ConsoleState): any[] {
80
+ if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
81
+ if (s.panel === 2) return s.approvals;
82
+ if (s.panel === 3) {
83
+ const states = ["proposed", "in_progress", "in_review", "changes_requested"];
84
+ return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
85
+ }
86
+ if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
87
+ return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
88
+ }
89
+ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
90
+ let s = pruneApprovals({ ...state }, now); const effects: ConsoleEffect[] = [];
91
+ const done = () => ({ state: s, effects });
92
+ if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
93
+ if (s.confirm) {
94
+ const confirm = s.confirm; s.confirm = undefined;
95
+ if (key === "y") {
96
+ if (confirm.type === "command") effects.push({ type: "command", args: confirm.args });
97
+ else if (s.approvals.some(a => a.id === confirm.id && a.options.some(o => o.optionId === confirm.option && o.kind.startsWith("allow")))) {
98
+ effects.push({ type: "permit", id: confirm.id, option: confirm.option });
99
+ s.approvals = s.approvals.filter(a => a.id !== confirm.id);
100
+ }
101
+ }
102
+ return done();
103
+ }
104
+ if (s.editing || s.input) {
105
+ if (key === "\x1b") { s.input = ""; s.editing = false; }
106
+ else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
107
+ else if (key === "\x15") s.input = "";
108
+ else if (key === "\x1b[A" || key === "\x1b[B") {
109
+ s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
110
+ s.input = s.history[s.historyIndex] ?? "";
111
+ } else if (key === "\r" || key === "\n") {
112
+ const input = s.input; s.input = ""; s.editing = false;
113
+ if (!input.trim()) return done();
114
+ try {
115
+ const args = parseConsoleCommand(input);
116
+ if (args[0] === "queue" && args[1] === "resolve") {
117
+ const reason = args.indexOf("--reason");
118
+ if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
119
+ }
120
+ s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
121
+ if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
122
+ else effects.push({ type: "command", args });
123
+ } catch (error) { s.notice = String((error as Error).message); }
124
+ } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
125
+ return done();
126
+ }
127
+ if (key === "q") { effects.push({ type: "exit" }); return done(); }
128
+ if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; return done(); }
129
+ if (key === ":") { s.editing = true; return done(); }
130
+ if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; return done(); }
131
+ if (key === "?") { s.help = !s.help; return done(); }
132
+ if (key === "\x1b") { s.detail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
133
+ if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
134
+ if (s.detail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
135
+ s.selection = Math.max(0, Math.min(panelRows(s).length - 1, s.selection + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
136
+ if (s.panel === 2) s.approvalIndex = s.selection;
137
+ return done();
138
+ }
139
+ if (key === "[" || key === "]") {
140
+ s.approvalIndex = (s.approvalIndex + (key === "[" ? -1 : 1) + s.approvals.length) % Math.max(1, s.approvals.length); return done();
141
+ }
142
+ const approval = s.approvals[s.approvalIndex];
143
+ if (s.optionChoice) {
144
+ const a = s.approvals.find(a => a.id === s.optionChoice); const options = a?.options.filter(o => o.kind.startsWith("allow")) ?? [];
145
+ const option = options[Number(key) - 1];
146
+ if (/^[1-9]$/.test(key) && a && option) { s.confirm = { type: "permission", id: a.id, peer: a.peer, option: option.optionId }; s.optionChoice = undefined; }
147
+ return done();
148
+ }
149
+ if (approval && (s.mode === "stream" || s.panel === 2)) {
150
+ if (key === "d") { effects.push({ type: "permit", id: approval.id }); s.approvals = s.approvals.filter(a => a.id !== approval.id); return done(); }
151
+ if (key === "a") {
152
+ const options = approval.options.filter(o => o.kind.startsWith("allow"));
153
+ if (options.length === 1) s.confirm = { type: "permission", id: approval.id, peer: approval.peer, option: options[0]!.optionId };
154
+ else if (options.length) s.optionChoice = approval.id;
155
+ return done();
156
+ }
157
+ if (key === "v") { s.detail = approval.title; s.detailOffset = 0; return done(); }
158
+ }
159
+ const row = panelRows(s)[s.selection];
160
+ if (s.mode === "panels" && row) {
161
+ if (key === "\r" || key === "\n") {
162
+ if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
163
+ else { s.detail = s.panel === 2 ? row.title : s.panel === 5 ? row.text : JSON.stringify(row, null, 2); s.detailOffset = 0; }
164
+ return done();
165
+ }
166
+ const prefill = s.panel === 1 && key === "p" ? `pause ${row.id}` : s.panel === 1 && key === "r" ? `resume ${row.id}` :
167
+ s.panel === 3 && key === "a" ? `task assign ${row.id} ` : s.panel === 3 && key === "r" ? `review ${row.id} ` :
168
+ s.panel === 4 && key === "r" ? `queue resolve ${row.id} --action retry --reason ` : undefined;
169
+ if (prefill) { s.input = prefill; s.editing = true; return done(); }
170
+ }
171
+ if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
172
+ const field = key === "f" ? "peer" : "kind"; const current = key === "f" ? s.peerFilter : s.kindFilter;
173
+ const values = [...new Set(s.events.map(e => e[field]).filter((x): x is string => !!x))];
174
+ const next = values[values.indexOf(current ?? "") + 1];
175
+ if (key === "f") s.peerFilter = next; else s.kindFilter = next;
176
+ s.selection = 0; return done();
177
+ }
178
+ if (s.mode === "stream" && !key.startsWith("\x1b")) { s.editing = true; s.input = terminalText(key).replace(/\n/g, ""); }
179
+ return done();
180
+ }
181
+ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
182
+ const permission = s.approvals[s.approvalIndex];
183
+ let prompt = s.editing ? `: ${s.input}` : "q quit | Tab panels | : command | a allow d deny v view [ ] select";
184
+ if (s.confirm?.type === "permission") prompt = `allow ${s.confirm.option} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
185
+ if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
186
+ if (s.optionChoice) prompt = s.approvals.find(a => a.id === s.optionChoice)?.options.filter(o => o.kind.startsWith("allow")).map((o, i) => `${i + 1}:${o.name}`).join(" | ") ?? "";
187
+ const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
188
+ const summary = Object.entries(s.peers).map(([id, p]) => `${id}:${p.state} q${p.queued ?? 0}${p.needsReview ? ` review${p.needsReview}` : ""}${p.paused ? " paused" : ""}`).join(" ");
189
+ const footer = [fit(summary + (quotas ? ` | ${quotas}` : ""), columns), fit(`${s.approvals.length} approvals${permission ? ` | ${permission.peer} ${permission.id} ${Math.max(0, Math.ceil((permission.expiresAt - now) / 1000))}s` : ""}${s.notice ? ` | ${s.notice}` : ""}`, columns), fit(prompt, columns)];
190
+ if (s.mode === "stream") return footer;
191
+ const title = ["Peers", "Approvals", "Tasks", "Queue", "Events"][s.panel - 1];
192
+ const lines = [fit(`agent-hub | 1 Peers 2 Approvals 3 Tasks 4 Queue 5 Events | ${title}`, columns), fit(`j/k move Enter detail Esc back ? keys${s.panel === 5 ? ` | f peer:${s.peerFilter ?? "all"} g kind:${s.kindFilter ?? "all"}` : ""}`, columns)];
193
+ if (s.help) lines.push(...wrap("Tab stream/panels; 1-5 panel; j/k or arrows move; Enter detail; Esc back; : command; q quit. Approvals: a allow (confirm y), d deny, v title, [ ] select. Peers: p pause, r resume. Tasks: a assign (confirm y), r review. Queue: r resolve (reason and confirm y). Events: f peer filter, g kind filter.", columns));
194
+ else if (s.detail) { const detail = wrap(s.detail, columns); lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - (rows - 5))))); }
195
+ else {
196
+ const data = panelRows(s); const capacity = Math.max(1, s.panel === 2 ? Math.floor((rows - 6) / 2) : rows - 6); const start = Math.max(0, s.selection - capacity + 1);
197
+ for (const [i, row] of data.slice(start, start + capacity).entries()) {
198
+ let text = "";
199
+ if (s.panel === 1) {
200
+ const b = s.budget[row.id];
201
+ text = `${row.id} ${row.state} ${row.attached === false ? "detached" : "attached"} q:${row.queued ?? 0} !:${row.queuedImportant ?? 0} review:${row.needsReview ?? 0} ${row.paused ? `paused:${JSON.stringify(row.paused)}` : ""} ${b?.paused ? `budget pause ${b.paused.reason} reset:${b.paused.resetsAt}` : ""} ${b?.windows?.map((w: any) => `${w.id}:${Math.round(w.used * 100)}% reset:${w.resetsAt ?? "?"}`).join(" ") ?? "quota:?"} model:${row.servedBy ?? row.requestedModel ?? "?"}`;
202
+ } else if (s.panel === 2) text = `${row.id} ${row.peer} ${Math.max(0, Math.ceil((row.expiresAt - now) / 1000))}s ${row.title}`;
203
+ else if (s.panel === 3) { const at = row.history?.at(-1)?.at ?? row.updated ?? row.created; text = `#${row.id} ${row.state}${row.ready ? " ready" : ""} ${row.owner ?? "-"} review:${row.reviewer ?? "-"} ${row.class} age:${at ? Math.max(0, Math.floor((now - at) / 1000)) + "s" : "?"} ${row.title}`; }
204
+ else if (s.panel === 4) text = `${row.id} ${row.peer} ${row.state} rev:${row.revision} age:${row.createdAt ? Math.max(0, Math.floor((now - row.createdAt) / 1000)) + "s" : "?"}`;
205
+ else text = row.text;
206
+ lines.push(fit(`${start + i === s.selection ? ">" : " "} ${text}`, columns));
207
+ }
208
+ if (!data.length) lines.push("(empty)");
209
+ if (s.panel === 2 && permission) lines.push(...wrap(permission.title, columns).slice(0, Math.max(0, rows - 3 - lines.length)));
210
+ }
211
+ const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push("");
212
+ return [...body, ...footer];
213
+ }
@@ -0,0 +1,201 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ import type { ControlClient } from "../hub/control-client.ts";
4
+ import { contextLine } from "./status-lines.ts";
5
+ import { renderTailEvent } from "./tail-render.ts";
6
+ import { initialConsoleState, pruneApprovals, reduceConsole, renderConsole, terminalText, wrap } from "./console-state.ts";
7
+ import type { ConsoleEffect, ConsoleEvent } from "./console-state.ts";
8
+
9
+ export interface ConsoleTerminal {
10
+ columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
11
+ write(text: string): void;
12
+ raw(enabled: boolean): void;
13
+ onData(listener: (text: string) => void): () => void;
14
+ onResize(listener: () => void): () => void;
15
+ onSignal(listener: () => void): () => void;
16
+ onError?(listener: (error: Error) => void): () => void;
17
+ }
18
+ export interface ConsoleOptions {
19
+ client: Pick<ControlClient, "request" | "send" | "close" | "onPush" | "onClose">;
20
+ cwd: string; stateDir: string; panels?: boolean; columns?: number; rows?: number;
21
+ terminal?: ConsoleTerminal;
22
+ /** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
23
+ runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
24
+ pollMs?: number;
25
+ }
26
+ export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
27
+ function nativeTerminal(): ConsoleTerminal {
28
+ return {
29
+ get columns() { return process.stdout.columns ?? 80; }, get rows() { return process.stdout.rows ?? 24; },
30
+ get isTTY() { return !!process.stdin.isTTY && !!process.stdout.isTTY; }, get isRaw() { return process.stdin.isRaw; },
31
+ write: text => { process.stdout.write(text); }, raw: enabled => { process.stdin.setRawMode(enabled); },
32
+ onData: listener => {
33
+ const decoder = new StringDecoder("utf8"); const onData = (data: Buffer) => listener(decoder.write(data));
34
+ process.stdin.on("data", onData); process.stdin.resume();
35
+ return () => { process.stdin.off("data", onData); process.stdin.pause(); };
36
+ },
37
+ onResize: listener => { process.stdout.on("resize", listener); return () => { process.stdout.off("resize", listener); }; },
38
+ onError: listener => {
39
+ const rejected = (reason: unknown) => listener(reason instanceof Error ? reason : new Error(String(reason)));
40
+ process.on("uncaughtException", listener); process.on("unhandledRejection", rejected);
41
+ return () => { process.off("uncaughtException", listener); process.off("unhandledRejection", rejected); };
42
+ },
43
+ onSignal: listener => { process.on("SIGINT", listener); process.on("SIGTERM", listener); return () => { process.off("SIGINT", listener); process.off("SIGTERM", listener); }; },
44
+ };
45
+ }
46
+ /** One terminal client, no daemon ownership. All text crosses terminalText before writing. */
47
+ export async function runConsole(options: ConsoleOptions): Promise<void> {
48
+ const { client } = options; const terminal = options.terminal ?? nativeTerminal();
49
+ let state = initialConsoleState(!!options.panels);
50
+ let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
51
+ let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
52
+ let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
53
+ const priorPush = client.onPush; const priorClose = client.onClose; const priorRaw = !!terminal.isRaw;
54
+ const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
55
+ let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
56
+ let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
57
+ const safeWrite = (text: string) => terminal.write(terminalText(text));
58
+ const writeStream = (text: string) => {
59
+ terminal.write(`\x1b[${rows - 3};1H`);
60
+ for (const line of wrap(text, columns)) { safeWrite(line); terminal.write("\r\n"); }
61
+ };
62
+ const stream = (event: ConsoleEvent) => {
63
+ state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
64
+ if (plain) safeWrite(event.text + "\n");
65
+ else if (state.mode === "stream") writeStream(event.text);
66
+ else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
67
+ };
68
+ const draw = () => {
69
+ if (!active || plain) return;
70
+ if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
71
+ else if (state.mode === "stream" && inAlternate) {
72
+ terminal.write(`\x1b[?1049l\x1b[1;${rows - 3}r`); inAlternate = false;
73
+ if (droppedStream) writeStream(`${droppedStream} older panel-mode events omitted from console memory; inspect hub.log for the full stream.`);
74
+ for (const event of pendingStream) writeStream(event.text);
75
+ pendingStream = []; droppedStream = 0;
76
+ }
77
+ terminal.write(state.mode === "stream" ? `\x1b[1;${rows - 3}r` : "\x1b[r");
78
+ const lines = renderConsole(state, columns, rows);
79
+ const start = state.mode === "stream" ? rows - 2 : 1;
80
+ for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); safeWrite(line); }
81
+ terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(lines.at(-1) ?? "") + 1)}H\x1b[?25h`);
82
+ };
83
+ const stop = (reason?: string) => {
84
+ if (!active) return;
85
+ active = false;
86
+ for (const timer of timers) clearInterval(timer);
87
+ for (const remove of removers) remove();
88
+ cancelChild?.(); cancelChild = undefined;
89
+ client.onPush = priorPush; client.onClose = priorClose;
90
+ if (terminal.isTTY) {
91
+ try { terminal.raw(priorRaw); } finally { terminal.write(RESTORE_CONSOLE + "\r\n"); }
92
+ }
93
+ client.close();
94
+ safeWrite(`${reason ? reason + "\n" : ""}Left console; the hub keeps running (ahub kill stops it).\n`);
95
+ resolveDone();
96
+ };
97
+ const refresh = async () => {
98
+ if (polling || !active) return; polling = true;
99
+ try {
100
+ const panels = state.mode === "panels" && !plain;
101
+ const replies = await Promise.all([
102
+ client.request({ t: "status" }, 3000), client.request({ t: "budget" }, 3000),
103
+ ...(panels ? [client.request({ t: "task", op: "hub_task_list", args: {} }, 3000), client.request({ t: "queue", op: "list" }, 3000), client.request({ t: "task", op: "hub_task_list", args: { ready: true } }, 3000)] : []),
104
+ ]);
105
+ if (!active) return;
106
+ const [status, budget, tasks, queue, ready] = replies;
107
+ if (status.status?.peers) state.peers = status.status.peers;
108
+ if (budget.budget) state.budget = budget.budget;
109
+ if (tasks?.ok && state.mode === "panels") { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
110
+ const readyIds = new Set(ready?.ok ? JSON.parse(ready.text).map((task: any) => task.id) : []);
111
+ state.tasks = parsed.map(task => ({ ...task, ready: readyIds.has(task.id) }));
112
+ } }
113
+ if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
114
+ const error = replies.find(reply => reply.ok === false)?.error; if (error) state.notice = String(error);
115
+ draw();
116
+ } catch (error) { if (active) { state.notice = String((error as Error).message); draw(); } }
117
+ finally { polling = false; }
118
+ };
119
+ const run = options.runCommand ?? ((args, output, finished) => {
120
+ const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
121
+ cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
122
+ });
123
+ const read = async (source: ReadableStream<Uint8Array>) => {
124
+ const decoder = new TextDecoder(); const reader = source.getReader();
125
+ try { while (true) { const { value, done } = await reader.read(); if (done) break; output(decoder.decode(value, { stream: true })); } const end = decoder.decode(); if (end) output(end); }
126
+ finally { reader.releaseLock(); }
127
+ };
128
+ void Promise.all([read(child.stdout), read(child.stderr), child.exited]).then(([, , code]) => { if (code) output(`command exited ${code}`); }).catch(error => output(String(error.message))).finally(finished);
129
+ return () => { child.kill(); };
130
+ });
131
+ const effect = async (action: ConsoleEffect) => {
132
+ if (action.type === "exit") return stop();
133
+ if (action.type === "permit") { client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) }); return; }
134
+ if (action.type === "show") {
135
+ const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
136
+ if (!active) return;
137
+ state.detailOffset = 0;
138
+ state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? String(result.text) : JSON.stringify(result.delivery, null, 2);
139
+ draw(); return;
140
+ }
141
+ if (childRunning) { state.notice = "a command is already running"; draw(); return; }
142
+ childRunning = true;
143
+ stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
144
+ try {
145
+ cancelChild = run(action.args, text => { if (active) { stream({ text, kind: "command" }); draw(); } }, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
146
+ } catch (error) { childRunning = false; state.notice = String((error as Error).message); draw(); }
147
+ };
148
+ const handle = (key: string) => {
149
+ if (!active) return;
150
+ const result = reduceConsole(state, key); state = result.state;
151
+ if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; state.notice = "panels need at least 80x24"; }
152
+ for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
153
+ draw();
154
+ if (key === "v" && state.detail && state.mode === "stream") { stream({ text: state.detail, kind: "permission" }); state.detail = undefined; draw(); }
155
+ if (key === "\t") void refresh();
156
+ };
157
+ client.onPush = msg => {
158
+ if (!active) return;
159
+ try {
160
+ if (msg.t === "event") stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t });
161
+ else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading)}`, peer: msg.peer, kind: "context" });
162
+ else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
163
+ else if (msg.t === "permission") {
164
+ stream({ text: ` ? ${msg.peer} asks permission: ${String(msg.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${msg.id} <${msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ")}> | deny`, peer: msg.peer, kind: "permission" });
165
+ if (typeof msg.expiresAt === "number" && msg.expiresAt > Date.now() && !state.approvals.some(a => a.id === msg.id)) state.approvals.push(msg);
166
+ } else if (msg.t === "permission_closed") {
167
+ state.approvals = state.approvals.filter(a => a.id !== msg.id); state = pruneApprovals(state, Date.now());
168
+ }
169
+ draw();
170
+ } catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
171
+ };
172
+ client.onClose = (_code, reason) => stop(`Hub connection closed${reason ? `: ${reason}` : ""}`);
173
+ try {
174
+ if (plain) { state.mode = "stream"; safeWrite("Console panels need a terminal of at least 80x24; showing the plain stream.\n"); }
175
+ if (terminal.isTTY) {
176
+ terminal.raw(true);
177
+ removers.push(terminal.onData(text => {
178
+ // A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
179
+ if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
180
+ state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
181
+ state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
182
+ } else handle(text);
183
+ }));
184
+ removers.push(terminal.onResize(() => {
185
+ columns = terminal.columns; rows = terminal.rows; plain = columns < 80 || rows < 24;
186
+ if (plain) {
187
+ if (inAlternate) { terminal.write("\x1b[?1049l"); inAlternate = false; }
188
+ terminal.write("\x1b[r"); state.mode = "stream"; state.notice = "panels need at least 80x24";
189
+ safeWrite("\nTerminal below 80x24; showing the plain stream.\n");
190
+ }
191
+ draw();
192
+ }));
193
+ }
194
+ removers.push(terminal.onSignal(() => stop()));
195
+ if (terminal.onError) removers.push(terminal.onError(error => { process.exitCode = 1; stop(`Console error: ${error.message}`); }));
196
+ timers.push(setInterval(() => { try { state = pruneApprovals(state, Date.now()); draw(); } catch (error) { stop(`Console error: ${String((error as Error).message)}`); } }, 1000));
197
+ timers.push(setInterval(() => void refresh(), Math.max(2000, options.pollMs ?? 4000)));
198
+ draw(); client.send({ t: "tail" }); void refresh(); await done;
199
+ } catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
200
+ finally { stop(); }
201
+ }
@@ -5,15 +5,22 @@
5
5
  // call; the hub confirms it from the transcript row Claude Code writes for it, by tool use id, before it counts as seen.
6
6
  import { ControlClient } from "../hub/control-client.ts";
7
7
 
8
+ /** A library call may target another hub; only the managed command hook inherits that target's native identity. */
9
+ export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.ProcessEnv = process.env): { nativeInstanceId?: string; nativeLaunchId?: string } {
10
+ if (env.AGENTHUB_STATE_DIR !== stateDir || env.AGENTHUB_PEER_ID !== peer) return {};
11
+ return { ...(env.AGENTHUB_INSTANCE_ID ? { nativeInstanceId: env.AGENTHUB_INSTANCE_ID } : {}), ...(env.AGENTHUB_LAUNCH_ID ? { nativeLaunchId: env.AGENTHUB_LAUNCH_ID } : {}) };
12
+ }
13
+
8
14
  /** The hook's stdout for one Claude Code hook input, or undefined for none. */
9
15
  export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000): Promise<string | undefined> {
10
16
  const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
11
- const phase = ({ PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
17
+ const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
12
18
  const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
13
19
  try {
14
20
  const res = await hub.request({
15
21
  t: "facts",
16
22
  phase,
23
+ ...nativeHookIdentity(stateDir, peer),
17
24
  tool: typeof input.tool_name === "string" ? input.tool_name : "",
18
25
  input: input.tool_input ?? {},
19
26
  ...(typeof input.tool_use_id === "string" ? { toolUseId: input.tool_use_id } : {}),
@@ -0,0 +1,66 @@
1
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cliCommandLabel } from "./identity.ts";
4
+
5
+ export interface CliAudit {
6
+ v: 1; id: string; at: number; peer: string; command: string; outcome: "run" | "refused" | "invalid";
7
+ }
8
+ const MAX_FILES = 256;
9
+ const MAX_BYTES = 64 * 1024;
10
+ const MAX_ROW_BYTES = 512;
11
+ const READY = /^[a-f0-9-]{36}\.json$/;
12
+ const PENDING = /^[a-f0-9-]{36}\.tmp$/;
13
+ const valid = (row: any): row is CliAudit => row?.v === 1 && typeof row.id === "string" && /^[a-f0-9-]{36}$/.test(row.id)
14
+ && typeof row.at === "number" && Number.isFinite(row.at) && row.at > 0
15
+ && typeof row.peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row.peer) && !/\s/.test(row.peer)
16
+ && typeof row.command === "string" && row.command.length <= 64 && row.command === cliCommandLabel(...splitLabel(row.command))
17
+ && ["run", "refused", "invalid"].includes(row.outcome);
18
+ const splitLabel = (label: string): [string, string[]] => { const [command = "unknown", ...args] = label.split(" "); return [command, args]; };
19
+
20
+ /**
21
+ * An ids-only outbox, published atomically without a writer lock. Concurrent writers can overshoot
22
+ * the checked 256-file / 64 KiB ceiling by at most one bounded row per concurrent writer.
23
+ * Unpublished temp files count against the ceiling, so a crashed writer cannot grow the spool forever.
24
+ */
25
+ export function recordCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]): void {
26
+ const row: CliAudit = { v: 1, id: crypto.randomUUID(), at: Date.now(), peer, command, outcome };
27
+ if (!valid(row)) throw new Error("invalid CLI audit identifiers");
28
+ const dir = join(stateDir, "cli-audit");
29
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
30
+ const files = readdirSync(dir).filter(name => READY.test(name) || PENDING.test(name));
31
+ let bytes = 0;
32
+ for (const name of files) {
33
+ try { bytes += statSync(join(dir, name)).size; }
34
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
35
+ }
36
+ const text = JSON.stringify(row) + "\n";
37
+ if (Buffer.byteLength(text) > MAX_ROW_BYTES || files.length >= MAX_FILES || bytes + Buffer.byteLength(text) > MAX_BYTES) throw new Error("CLI audit outbox is full");
38
+ const pending = join(dir, `${row.id}.tmp`);
39
+ try {
40
+ writeFileSync(pending, text, { mode: 0o600, flag: "wx" });
41
+ renameSync(pending, join(dir, `${row.id}.json`));
42
+ } catch (error) {
43
+ try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ }
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ /** Consume ready files only; rebuild fixed fields so titles or other injected text cannot be rendered. */
49
+ export function drainCliAudits(stateDir: string): CliAudit[] {
50
+ const dir = join(stateDir, "cli-audit");
51
+ let files: string[];
52
+ try { files = readdirSync(dir).filter(name => READY.test(name)).sort(); }
53
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
54
+ const rows: CliAudit[] = [];
55
+ for (const name of files) {
56
+ const path = join(dir, name);
57
+ try {
58
+ if (statSync(path).size <= MAX_ROW_BYTES) {
59
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
60
+ if (valid(parsed) && `${parsed.id}.json` === name) rows.push({ v: 1, id: parsed.id, at: parsed.at, peer: parsed.peer, command: parsed.command, outcome: parsed.outcome });
61
+ }
62
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
63
+ try { unlinkSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
64
+ }
65
+ return rows;
66
+ }
@@ -0,0 +1,54 @@
1
+ export type CliIdentity =
2
+ | { role: "console" }
3
+ | { role: "tools"; peer: string }
4
+ | { role: "invalid"; reason: string };
5
+
6
+ /** Honest defaults for native shells, not authentication against an agent that can edit its environment. */
7
+ export function detectCliIdentity(env: Readonly<Record<string, string | undefined>>): CliIdentity {
8
+ const hub = env.AGENTHUB_PEER_ID;
9
+ const claude = env.CLAUDECODE;
10
+ const session = env.CLAUDE_CODE_SESSION_ID;
11
+ const codex = env.CODEX_THREAD_ID;
12
+ const invalid = (reason: string): CliIdentity => ({ role: "invalid", reason });
13
+ if (hub !== undefined && (!/^[a-z][a-z0-9-]{0,31}$/.test(hub) || /\s/.test(hub) || ["user", "hub", "digest"].includes(hub))) return invalid("invalid AGENTHUB_PEER_ID marker");
14
+ if (claude !== undefined && claude !== "1") return invalid("invalid CLAUDECODE marker");
15
+ if (session !== undefined && (!session || /\s/.test(session))) return invalid("invalid CLAUDE_CODE_SESSION_ID marker");
16
+ if (codex !== undefined && (!codex || /\s/.test(codex))) return invalid("invalid CODEX_THREAD_ID marker");
17
+ if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
18
+ if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
19
+ const native = claude !== undefined ? "claude" : codex !== undefined ? "codex" : undefined;
20
+ if (hub !== undefined && native !== undefined && hub !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
21
+ const peer = hub ?? native;
22
+ return peer === undefined ? { role: "console" } : { role: "tools", peer };
23
+ }
24
+
25
+ export type PeerCommandAccess = "allowed" | "conductor" | "console";
26
+
27
+ /** A closed list: unknown commands cannot acquire console authority through an agent shell. */
28
+ export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
29
+ if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
30
+ if (command === "task") {
31
+ if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
32
+ if (["assign", "escalate"].includes(args[0] ?? "")) return "conductor";
33
+ return "console";
34
+ }
35
+ if (command === "route") return args[0] === "explain" ? "allowed" : "console";
36
+ if (command === "budget") return !args.length || args.every((arg) => arg === "--json") ? "allowed" : "console";
37
+ if (["claude", "codex", "kimi", "pi", "local", "pause", "resume"].includes(command)) return "conductor";
38
+ if (["help", "--help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
39
+ return "console";
40
+ }
41
+
42
+ /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
43
+ export function cliCommandLabel(command: string, args: readonly string[]): string {
44
+ if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "status", "logs", "export", "report", "facts", "check-path", "turns", "undo", "kill", "doctor"].includes(command)) return "unknown";
45
+ if (["task", "queue", "budget", "route", "recovery", "models", "projects"].includes(command)) {
46
+ const sub = args[0];
47
+ if (sub && ["propose", "show", "assign", "escalate", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "setup", "start", "stop", "remove"].includes(sub)) return `${command} ${sub}`;
48
+ }
49
+ return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
50
+ }
51
+
52
+ export function peerCommandRefusal(peer: string, command: string): string {
53
+ return `${peer} cannot run ahub ${command}; the person runs it in ahub console or a terminal`;
54
+ }
package/src/cli/launch.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
2
  import { readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
+ import { peerChildEnv } from "../hub/child-process.ts";
4
5
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
5
6
 
7
+ /** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
8
+ export function nativeLaunchEnv(tool: "claude" | "codex" | "pi", source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
9
+ const env = peerChildEnv(tool, source);
10
+ delete env.AGENTHUB_UNATTENDED;
11
+ return env;
12
+ }
13
+
6
14
  /** A scoped candidate MCP server can be selected without promoting the installed plugin. */
7
15
  function claudeChannel(userArgs: string[]): string {
8
16
  const bundle = resolve(import.meta.dir, "../../plugins/agent-hub/server.js");
@@ -57,13 +65,16 @@ export interface FactsHook {
57
65
  stateDir: string;
58
66
  /** The same hook transport can observe native turns without injecting facts. */
59
67
  purpose?: "facts" | "idle" | "facts-and-idle";
68
+ /** Observe native sessions/turns for every enabled facts, idle or conductor hook configuration. */
69
+ observeNative?: boolean;
60
70
  }
61
71
 
62
72
  /** Shared by actual launch and read-only preview; advisory facts remain disabled. */
63
- export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean } }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook | undefined {
73
+ export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean }; roles?: Record<string, string[]> }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook | undefined {
64
74
  const facts = config.coordination === "turn-free";
65
75
  const idle = config.task_sweep?.enabled === true;
66
- return facts || idle ? { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : "idle" } : undefined;
76
+ const observeNative = facts || idle || config.roles?.claude?.includes("conductor") === true;
77
+ return facts || idle || observeNative ? { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : "idle", ...(observeNative ? { observeNative: true } : {}) } : undefined;
67
78
  }
68
79
 
69
80
  /**
@@ -76,6 +87,7 @@ export function sessionSettings(tee: StatusLineTee, facts?: FactsHook): string {
76
87
  if (facts) {
77
88
  const hooks = [{ type: "command", command: `AGENTHUB_STATE_DIR=${sh(facts.stateDir)} bun ${sh(facts.script)}`, timeout: 5 }];
78
89
  settings.hooks = { PreToolUse: [{ matcher: "*", hooks }], PostToolUse: [{ matcher: "*", hooks }], Stop: [{ hooks }] };
90
+ if (facts.observeNative) Object.assign(settings.hooks as object, { SessionStart: [{ matcher: "*", hooks }], UserPromptSubmit: [{ hooks }] });
79
91
  }
80
92
  return JSON.stringify(settings);
81
93
  }
@@ -101,6 +113,7 @@ export function buildLaunch(
101
113
  ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : "",
102
114
  ctx.facts && own && ctx.facts.purpose !== "idle" ? "note: you passed --settings, so the hub's turn-free facts hooks are off for this session: Claude will not see the other agents' changes at its tool calls." : "",
103
115
  ctx.facts && own && (ctx.facts.purpose === "idle" || ctx.facts.purpose === "facts-and-idle") ? "note: you passed --settings, so native idle observation hooks are off for this session: task idle sweeps cannot verify Claude between turns." : "",
116
+ ctx.facts?.observeNative && own ? "note: you passed --settings, so native session/turn observation hooks are off: completed turns and supervision tokens may be unavailable." : "",
104
117
  ].filter(Boolean);
105
118
  return {
106
119
  cmd: "claude",