@staix/agent-hub 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -5
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +614 -104
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +215 -19
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #689: the console's structural config form (Settings panel, key `c`). A pure reducer: it collects answers, builds the one request the
|
|
3
|
+
* hub validates (`config_edit_preview`, then `config_edit_apply`) and never judges a value itself. The loaders' reasons come back from
|
|
4
|
+
* the hub and are shown as they are. A tier 3 key is confirmed by typing its name, never by `y`.
|
|
5
|
+
*/
|
|
6
|
+
export interface ConfigEditBody { file: "config.local.json" | "sdlc.toml"; revision: string; set: { path: string[]; value: unknown }[] }
|
|
7
|
+
export type ConfigFormKind = "instance" | "sdlc" | "other";
|
|
8
|
+
export interface ConfigForm {
|
|
9
|
+
kind: ConfigFormKind; token: number;
|
|
10
|
+
/** The instance ids the hub reports: an id in it changes leaf by leaf, a new id is added as one object. */
|
|
11
|
+
known?: readonly string[];
|
|
12
|
+
/** True until the person has chosen what to edit (1, 2 or 3). */
|
|
13
|
+
choosing?: boolean;
|
|
14
|
+
phase: "fields" | "preview" | "reason" | "confirm" | "typed";
|
|
15
|
+
step: number; answers: string[]; text: string;
|
|
16
|
+
edit?: ConfigEditBody; lines?: string[]; reason?: string;
|
|
17
|
+
/** The key a tier 3 edit must be confirmed with, from the hub's preview. */
|
|
18
|
+
typed?: string; warning?: string;
|
|
19
|
+
}
|
|
20
|
+
export type ConfigFormEffect =
|
|
21
|
+
| { type: "config_preview"; token: number; edit: ConfigEditBody }
|
|
22
|
+
| { type: "config_write"; edit: ConfigEditBody; reason: string; confirm?: string };
|
|
23
|
+
export interface ConfigFormStep { form?: ConfigForm; effect?: ConfigFormEffect; note?: string }
|
|
24
|
+
|
|
25
|
+
const FIELDS: Record<ConfigFormKind, readonly { name: string; ask: string }[]> = {
|
|
26
|
+
instance: [
|
|
27
|
+
{ name: "id", ask: "instance id, <kind>-<name> (e.g. codex-review)" }, { name: "kind", ask: "kind (claude, codex, kimi, jcode, ...; empty for an instance that exists already)" },
|
|
28
|
+
{ name: "roles", ask: "roles, comma list (planner, implementer, verifier, reviewer; empty leaves it)" },
|
|
29
|
+
{ name: "start_mode", ask: "start_mode (headless; empty leaves it, clear removes it)" }, { name: "review_mode", ask: "review_mode (fresh; empty leaves it unset)" },
|
|
30
|
+
{ name: "worktree", ask: "worktree path (empty leaves it unset)" }, { name: "permission_mode", ask: "permission_mode (ask, ask-when-needed, never-ask; empty leaves it unset)" },
|
|
31
|
+
{ name: "readOnly", ask: "readOnly (true or false; empty leaves it unset)" },
|
|
32
|
+
],
|
|
33
|
+
sdlc: [{ name: "mode", ask: "sdlc mode (off, advise, enforce; empty leaves it)" }, { name: "required_checks", ask: "required checks, comma list (empty leaves them)" }],
|
|
34
|
+
other: [{ name: "path", ask: "key path, dot separated (e.g. review.pool)" }, { name: "json", ask: "JSON value (empty removes the key)" }],
|
|
35
|
+
};
|
|
36
|
+
const a1x = (text: string | undefined): text is string => !!text;
|
|
37
|
+
const list = (text: string): string[] => text.split(",").map(item => item.trim()).filter(Boolean);
|
|
38
|
+
|
|
39
|
+
/** `1` an instance, `2` the lifecycle, `3` another structural key; anything else opens nothing. */
|
|
40
|
+
export function openConfigForm(choice: string, token: number, known: readonly string[] = []): ConfigForm | undefined {
|
|
41
|
+
const kind = ({ "1": "instance", "2": "sdlc", "3": "other" } as const)[choice as "1"];
|
|
42
|
+
return kind ? { kind, token, phase: "fields", step: 0, answers: [], text: "", ...(known.length ? { known } : {}) } : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function configFormPrompt(form: ConfigForm): string {
|
|
46
|
+
if (form.choosing) return "edit: 1 instance 2 sdlc lifecycle 3 another key Esc cancel";
|
|
47
|
+
if (form.phase === "fields") return `${FIELDS[form.kind][form.step]!.ask}: ${form.text}`;
|
|
48
|
+
if (form.phase === "preview") return "checking the change with the hub's loaders; Esc cancels";
|
|
49
|
+
if (form.phase === "reason") return `reason: ${form.text}`;
|
|
50
|
+
if (form.phase === "typed") return `${form.warning ?? "this runs code with the daemon's rights"}; type ${form.typed} to confirm: ${form.text}`;
|
|
51
|
+
return `write ${form.edit?.file} (${form.reason})? y/N`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The request the answers describe, or the first reason they cannot. */
|
|
55
|
+
function build(form: ConfigForm, revisions: Record<string, string>): { edit: ConfigEditBody } | { note: string } {
|
|
56
|
+
const [a0 = "", a1 = ""] = form.answers;
|
|
57
|
+
if (form.kind === "instance") {
|
|
58
|
+
const names = ["kind", "roles", "start_mode", "review_mode", "worktree", "permission_mode", "readOnly"] as const;
|
|
59
|
+
const typed = names.flatMap((name, index) => a1x(form.answers[index + 1]) ? [[name, form.answers[index + 1]!] as const] : []);
|
|
60
|
+
const ro = typed.find(([name]) => name === "readOnly");
|
|
61
|
+
if (ro && !["true", "false", "clear"].includes(ro[1])) return { note: "readOnly is true or false (or clear)" };
|
|
62
|
+
const convert = (name: string, text: string): unknown => name === "roles" ? list(text) : name === "readOnly" ? text === "true" : text;
|
|
63
|
+
const file = revisions["config.local.json"] ?? "absent";
|
|
64
|
+
if (form.known?.includes(a0)) {
|
|
65
|
+
// An existing instance changes leaf by leaf: a blank field keeps its value, and a tier 3 field it holds is never part of the request.
|
|
66
|
+
if (!typed.length) return { note: "type a field to change, or clear to remove one" };
|
|
67
|
+
return { edit: { file: "config.local.json", revision: file, set: typed.map(([name, text]) => ({ path: ["instances", a0, name], value: text === "clear" ? null : convert(name, text) })) } };
|
|
68
|
+
}
|
|
69
|
+
if (!a1) return { note: "the kind is required for a new instance" };
|
|
70
|
+
const value: Record<string, unknown> = {};
|
|
71
|
+
for (const [name, text] of typed) if (text !== "clear") value[name] = convert(name, text);
|
|
72
|
+
return { edit: { file: "config.local.json", revision: file, set: [{ path: ["instances", a0], value }] } };
|
|
73
|
+
}
|
|
74
|
+
if (form.kind === "sdlc") {
|
|
75
|
+
const set: ConfigEditBody["set"] = [];
|
|
76
|
+
if (a0) set.push({ path: ["mode"], value: a0 });
|
|
77
|
+
if (a1) set.push({ path: ["required_checks"], value: list(a1) });
|
|
78
|
+
if (!set.length) return { note: "give a mode or the required checks" };
|
|
79
|
+
return { edit: { file: "sdlc.toml", revision: revisions["sdlc.toml"] ?? "absent", set } };
|
|
80
|
+
}
|
|
81
|
+
let value: unknown = null;
|
|
82
|
+
if (a1.trim()) { try { value = JSON.parse(a1); } catch { return { note: "the value is not valid JSON (strings need their quotes)" }; } }
|
|
83
|
+
return { edit: { file: "config.local.json", revision: revisions["config.local.json"] ?? "absent", set: [{ path: a0.split("."), value }] } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One key. A chunk longer than one key is pasted text: it is text in a text phase and never an answer to y/N. */
|
|
87
|
+
export function reduceConfigForm(form: ConfigForm, key: string, revisions: Record<string, string>): ConfigFormStep {
|
|
88
|
+
if (key === "\x1b") return { form: undefined };
|
|
89
|
+
if (form.choosing) {
|
|
90
|
+
const chosen = key.length === 1 ? openConfigForm(key, form.token, form.known) : undefined;
|
|
91
|
+
return { form: chosen ?? form };
|
|
92
|
+
}
|
|
93
|
+
if (form.phase === "preview") return { form };
|
|
94
|
+
if (form.phase === "confirm") {
|
|
95
|
+
if (key === "n") return { form: undefined };
|
|
96
|
+
if (key !== "y") return { form };
|
|
97
|
+
return { form: undefined, effect: { type: "config_write", edit: form.edit!, reason: form.reason! } };
|
|
98
|
+
}
|
|
99
|
+
const text = (current: ConfigForm): ConfigFormStep => {
|
|
100
|
+
if (key === "\x7f" || key === "\b") return { form: { ...current, text: Array.from(current.text).slice(0, -1).join("") } };
|
|
101
|
+
if (key === "\x15") return { form: { ...current, text: "" } };
|
|
102
|
+
if (key.startsWith("\x1b") || key === "\r" || key === "\n") return { form: current };
|
|
103
|
+
return { form: { ...current, text: (current.text + key.replace(/[\x00-\x1f\x7f]/g, "")).slice(0, 4000) } };
|
|
104
|
+
};
|
|
105
|
+
if (key !== "\r" && key !== "\n") return text(form);
|
|
106
|
+
const answer = form.text.trim();
|
|
107
|
+
if (form.phase === "reason") {
|
|
108
|
+
if (!answer || Buffer.byteLength(answer) > 400) return { form, note: "give a reason from 1 to 400 UTF-8 bytes" };
|
|
109
|
+
return { form: { ...form, reason: answer, text: "", phase: form.typed ? "typed" : "confirm" } };
|
|
110
|
+
}
|
|
111
|
+
if (form.phase === "typed") {
|
|
112
|
+
if (answer !== form.typed) return { form, note: `type exactly ${form.typed}` };
|
|
113
|
+
return { form: undefined, effect: { type: "config_write", edit: form.edit!, reason: form.reason!, confirm: form.typed } };
|
|
114
|
+
}
|
|
115
|
+
// phase "fields"
|
|
116
|
+
if (form.kind === "instance" && form.step === 0 && !answer) return { form, note: "an instance id is required" };
|
|
117
|
+
if (form.kind === "other" && form.step === 0 && (!answer || answer.startsWith(".") || answer.endsWith(".") || answer.includes(".."))) return { form, note: "give a key path such as review.pool" };
|
|
118
|
+
const answers = [...form.answers, answer];
|
|
119
|
+
if (form.step + 1 < FIELDS[form.kind].length) return { form: { ...form, answers, step: form.step + 1, text: "" } };
|
|
120
|
+
const built = build({ ...form, answers }, revisions);
|
|
121
|
+
if ("note" in built) return { form: { ...form, answers: answers.slice(0, -1), text: answer }, note: built.note };
|
|
122
|
+
return { form: { ...form, answers, text: "", phase: "preview", edit: built.edit }, effect: { type: "config_preview", token: form.token, edit: built.edit } };
|
|
123
|
+
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { sanitize } from "../hub/envelope.ts";
|
|
|
9
9
|
import type { SettingRow, SettingValue } from "../hub/settings.ts";
|
|
10
10
|
import { symbolFields, overlapFields } from "./console-symbols.ts";
|
|
11
11
|
import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
|
|
12
|
+
import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
|
|
13
|
+
import { windowKind } from "../hub/window-kind.ts";
|
|
12
14
|
import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
|
|
13
15
|
|
|
14
16
|
export interface Approval {
|
|
@@ -24,6 +26,8 @@ export interface SettingsEdit {
|
|
|
24
26
|
text: string; value?: SettingValue; reason?: string; lines?: string[]; confirm?: string; undoDigest?: string;
|
|
25
27
|
}
|
|
26
28
|
export interface ConsoleState {
|
|
29
|
+
/** #689: the structural config form (Settings panel, key c) and the file revisions the hub last reported. */
|
|
30
|
+
configForm?: ConfigForm; configRevisions?: Record<string, string>; configInstances?: string[];
|
|
27
31
|
settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
|
|
28
32
|
settingsEdit?: SettingsEdit; settingsSequence?: number;
|
|
29
33
|
pendingRestart?: { key: string; reason: string }[];
|
|
@@ -65,6 +69,7 @@ export interface ConsoleState {
|
|
|
65
69
|
export type Detail = string | Record<string, unknown>;
|
|
66
70
|
export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
|
|
67
71
|
{ type: "settings_preview"; token: number; key?: string; value?: SettingValue; undo: boolean } |
|
|
72
|
+
{ type: "config_preview"; token: number; edit: ConfigEditBody } | { type: "config_write"; edit: ConfigEditBody; reason: string; confirm?: string } |
|
|
68
73
|
{ type: "settings_write"; key?: string; value?: SettingValue; undo: boolean; reason: string; confirm?: string; undoDigest?: string } |
|
|
69
74
|
{ type: "permission_default"; peer: string; confirmed: boolean } | { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
|
|
70
75
|
export function initialConsoleState(panels = false): ConsoleState {
|
|
@@ -330,8 +335,25 @@ function taskFields(detail: Record<string, unknown>): [string, unknown][] {
|
|
|
330
335
|
const at = fields.findIndex(([key]) => key === "plan");
|
|
331
336
|
return at < 0 ? [...fields, ...block] : [...fields.slice(0, at + 1), ...block, ...fields.slice(at + 1)];
|
|
332
337
|
}
|
|
333
|
-
|
|
334
|
-
|
|
338
|
+
/**
|
|
339
|
+
* The Peers panel's detail row carries the whole peer, `budget` included. Its windows read as one line each: `5h 23% resets in 10m, native, claude status line, measured 6s ago`,
|
|
340
|
+
* so a person sees what each number is and where it came from (#285 AC7). A window without a kind (an older hub) is classified like the CLI does.
|
|
341
|
+
*/
|
|
342
|
+
export function budgetFields(budget: any, now: number): [string, unknown][] {
|
|
343
|
+
if (!budget || typeof budget !== "object") return [];
|
|
344
|
+
const windows = Array.isArray(budget.windows) ? budget.windows : [];
|
|
345
|
+
const lines = windows.map((w: any) => {
|
|
346
|
+
const stale = w.stale ? " (stale)" : w.superseded ? " (information only: a measurement outranks it)" : "";
|
|
347
|
+
return `${w.id} ${Math.round(Number(w.used) * 100)}%${typeof w.resetsAt === "number" ? ` resets ${relative(w.resetsAt, now)}` : ""}, ${windowKind({ id: String(w.id), source: typeof w.source === "string" ? w.source : "", kind: w.kind })}, ${typeof w.source === "string" ? w.source : "unknown source"}${typeof w.at === "number" ? `, measured ${relative(w.at, now)}` : ""}${stale}`;
|
|
348
|
+
});
|
|
349
|
+
return [["quota", lines.length ? lines : typeof budget.note === "string" ? budget.note : "no reading"], ...(lines.length && typeof budget.note === "string" ? [["quota note", budget.note] as [string, unknown]] : [])];
|
|
350
|
+
}
|
|
351
|
+
const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
|
|
352
|
+
if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
|
|
353
|
+
const { budget, ...rest } = detail as Record<string, unknown>;
|
|
354
|
+
const entries: [string, unknown][] = task ? taskFields(detail) : "budget" in detail && !task ? [...Object.entries(rest), ...budgetFields(budget, now)] : Object.entries(detail);
|
|
355
|
+
return fieldLines(entries, columns, now).flat();
|
|
356
|
+
};
|
|
335
357
|
const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
|
|
336
358
|
/** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
|
|
337
359
|
export function approvalFrom(msg: any): Approval {
|
|
@@ -490,6 +512,13 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
490
512
|
const done = () => ({ state: bound(s), effects });
|
|
491
513
|
const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
|
|
492
514
|
if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
|
|
515
|
+
if (s.configForm) {
|
|
516
|
+
const step = reduceConfigForm(s.configForm, key, s.configRevisions ?? {});
|
|
517
|
+
s.configForm = step.form;
|
|
518
|
+
if (step.note) notify(s, step.note, now);
|
|
519
|
+
if (step.effect) effects.push(step.effect);
|
|
520
|
+
return done();
|
|
521
|
+
}
|
|
493
522
|
if (s.settingsEdit) {
|
|
494
523
|
const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
|
|
495
524
|
if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
|
|
@@ -684,6 +713,14 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
684
713
|
s.settingsEdit = { token, panel: 1, key: row.key, undo: false, phase: "value", text: String(wip.wipLimit) };
|
|
685
714
|
return done();
|
|
686
715
|
}
|
|
716
|
+
if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && key === "c") {
|
|
717
|
+
const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
|
|
718
|
+
s.configForm = { kind: "other", token, phase: "fields", step: 0, answers: [], text: "", choosing: true, ...(s.configInstances?.length ? { known: s.configInstances } : {}) };
|
|
719
|
+
return done();
|
|
720
|
+
}
|
|
721
|
+
if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && key === "R" && s.pendingRestart?.length) {
|
|
722
|
+
s.input = "restart"; s.editing = true; return done();
|
|
723
|
+
}
|
|
687
724
|
if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && (key === "e" || key === "u")) {
|
|
688
725
|
const row = selectedPanelRow(s);
|
|
689
726
|
if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
|
|
@@ -962,8 +999,9 @@ function taskHints(row: any): string[] {
|
|
|
962
999
|
const settingValueText = (value: unknown): string => value === null || value === undefined ? "inherit" : Array.isArray(value) ? value.join(",") : String(value);
|
|
963
1000
|
function panelContext(s: ConsoleState, row: any): string[] {
|
|
964
1001
|
if (s.panel === 3) return [...(row ? taskHints(row) : []), ...(row && s.features?.includes("task_cancel") ? ["c cancel"] : []), s.backlogView ? "b open" : "b backlog", "n add"];
|
|
1002
|
+
// The structural config form and the restart prefill need no selected row (#689).
|
|
1003
|
+
if (s.panel === 6) return [...(row ? ["e edit"] : []), "c config", ...(s.settingsUndo ? ["u undo"] : []), ...(s.pendingRestart?.length ? ["R restart"] : [])];
|
|
965
1004
|
if (!row) return [];
|
|
966
|
-
if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
|
|
967
1005
|
if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
|
|
968
1006
|
if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"]; // D (the peer's whole backlog) is in the ? key table: it must not push j/k off an 80-column footer
|
|
969
1007
|
return [];
|
|
@@ -1044,6 +1082,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1044
1082
|
let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
|
|
1045
1083
|
if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
|
|
1046
1084
|
if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
|
|
1085
|
+
if (s.configForm) prompt = configFormPrompt(s.configForm);
|
|
1047
1086
|
if (s.settingsEdit) {
|
|
1048
1087
|
const edit = s.settingsEdit;
|
|
1049
1088
|
prompt = edit.phase === "value" ? `value for ${edit.key} (inherit removes): ${edit.text}` : edit.phase === "preview" ? "checking setting preview; Esc cancels" : edit.phase === "reason" ? `reason: ${edit.text}` : `${edit.undo ? "undo last setting" : `set ${edit.key}`} (${edit.reason})? y/N`;
|
|
@@ -1094,6 +1133,12 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1094
1133
|
const height = bodyHeight - noticeLines.length;
|
|
1095
1134
|
const lines: Span[][] = [];
|
|
1096
1135
|
if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer, undefined, createProjectAdapterCatalog())], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
|
|
1136
|
+
else if (s.configForm && !s.configForm.choosing && (s.configForm.lines?.length || s.configForm.phase !== "fields")) {
|
|
1137
|
+
// #689: what the hub's loaders said about the request, as the person will confirm it: the diff, then the decision.
|
|
1138
|
+
const form = s.configForm;
|
|
1139
|
+
lines.push(...fieldLines([["file", form.edit?.file ?? "-"], ["diff", (form.lines ?? ["checking with the hub's loaders..."]).join("\n")], ...(form.warning ? [["warning", form.warning] as [string, unknown]] : []),
|
|
1140
|
+
["decision", form.typed ? `Esc cancels; type ${form.typed} to confirm` : "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
|
|
1141
|
+
}
|
|
1097
1142
|
else if (s.settingsEdit) {
|
|
1098
1143
|
const edit = s.settingsEdit;
|
|
1099
1144
|
const row = s.settingsRows?.find(row => row.key === edit.key);
|
|
@@ -40,8 +40,9 @@ export function classifySymbols(task: any): SymbolBlock {
|
|
|
40
40
|
export function symbolFields(task: any): [string, unknown][] {
|
|
41
41
|
// Only a task as `task_show` shapes it (a numeric id and a state): any other detail an agent or a test hands the Tasks panel keeps its own layout.
|
|
42
42
|
if (!task || typeof task.id !== "number" || typeof task.state !== "string" || isPrivate(task)) return [];
|
|
43
|
+
const related: [string, unknown][] = task.relatedMemory ? [["related notes", task.relatedMemory.state === "known" && Number.isSafeInteger(task.relatedMemory.count) && task.relatedMemory.count >= 0 ? task.relatedMemory.count : "unknown"]] : [];
|
|
43
44
|
const block = classifySymbols(task);
|
|
44
|
-
if (block.state === "unknown") return [["symbols", "changed symbols: unknown (no code facts)"]];
|
|
45
|
+
if (block.state === "unknown") return [...related, ["symbols", "changed symbols: unknown (no code facts)"]];
|
|
45
46
|
const items: Record<string, string>[] = [
|
|
46
47
|
...block.matching.map(s => ({ matching: s.name, in: s.path })), ...block.unplanned.map(s => ({ unplanned: s.name, in: s.path })), ...block.untouched.map(name => ({ untouched: name })),
|
|
47
48
|
];
|
|
@@ -49,7 +50,7 @@ export function symbolFields(task: any): [string, unknown][] {
|
|
|
49
50
|
const note = [`changed symbols: ${block.matching.length} matching, ${block.unplanned.length} unplanned, ${block.untouched.length} untouched${block.head ? ` at ${block.head}` : ""}`,
|
|
50
51
|
block.complete ? "" : "incomplete: some changed symbols were not listed, so an untouched one may have changed",
|
|
51
52
|
"advisory: deleted symbols are not reported"].filter(Boolean).join("; ");
|
|
52
|
-
return [["symbols", shown.length ? shown : "none observed"], ["symbols note", items.length > shown.length ? `${note}; ${items.length - shown.length} more not shown` : note]];
|
|
53
|
+
return [...related, ["symbols", shown.length ? shown : "none observed"], ["symbols note", items.length > shown.length ? `${note}; ${items.length - shown.length} more not shown` : note]];
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
|
package/src/cli/console-usage.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { windowKind, type WindowKind } from "../hub/window-kind.ts";
|
|
1
2
|
import type { Counter, LimitsResult, UsageResult, UsageRow } from "../hub/usage-rollup.ts";
|
|
2
3
|
|
|
3
4
|
// #683 U3: what the console's Usage panel shows, as plain strings. No terminal code and no state import here: console-state.ts renders it.
|
|
@@ -32,7 +33,7 @@ export function usagePeers(usage: UsageState | undefined, budget: Record<string,
|
|
|
32
33
|
return [...ids].sort().map(id => ({ id }));
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
interface Budget { windows?: { id: string; used: number; resetsAt?: number }[]; paused?: { resetsAt: number } }
|
|
36
|
+
interface Budget { windows?: { id: string; used: number; resetsAt?: number; kind?: string; source?: string }[]; paused?: { resetsAt: number }; note?: string }
|
|
36
37
|
const windowText = (w: { id: string; used: number; resetsAt?: number }, rel: (at: number) => string) =>
|
|
37
38
|
`${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${rel(w.resetsAt)}` : ""}`;
|
|
38
39
|
const lastResets = (limits: LimitsResult | undefined, peer: string, count: number) =>
|
|
@@ -66,7 +67,9 @@ export function usageDetail(usage: UsageState | undefined, peer: string, budget:
|
|
|
66
67
|
const resets = lastResets(usage?.limits, peer, 3).map(h => `${h.window} ${rel(Date.parse(h.at))}`);
|
|
67
68
|
const out: Record<string, string> = {
|
|
68
69
|
peer,
|
|
69
|
-
quota: budget?.windows?.length ? budget.windows.map(w => windowText(w, rel)).join(", ") : "no reading",
|
|
70
|
+
quota: budget?.windows?.length ? budget.windows.map(w => windowText(w, rel)).join(", ") : budget?.note ?? "no reading",
|
|
71
|
+
// What each number is and where it came from (#285 AC7), in its own field so the quota line keeps its shape.
|
|
72
|
+
...(budget?.windows?.length ? { "quota sources": budget.windows.map(w => `${w.id} ${windowKind({ id: w.id, source: w.source, kind: w.kind as WindowKind | undefined })}${w.source ? `, ${w.source}` : ""}`).join("; ") } : {}),
|
|
70
73
|
pause: budget?.paused ? `budget pause, resumes ${rel(budget.paused.resetsAt)}` : "none",
|
|
71
74
|
"last resets": resets.length ? resets.join(", ") : "none recorded",
|
|
72
75
|
today: splitText(rowOf(usage?.today, peer)),
|
package/src/cli/console.ts
CHANGED
|
@@ -102,7 +102,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
102
102
|
let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
|
|
103
103
|
let priorStreamFooterStart: number | undefined;
|
|
104
104
|
let lastExpandedNotice = "";
|
|
105
|
-
const notice = (text: string) => notify(state, text, Date.now());
|
|
105
|
+
const notice = (text: string, tone: Tone = "failure") => notify(state, text, Date.now(), tone);
|
|
106
106
|
const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
|
|
107
107
|
const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
|
|
108
108
|
// Tabs are flattened before measuring, as wrap() does; commands already wrap cells at their own offsets.
|
|
@@ -226,7 +226,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
226
226
|
state.recoveryNeed = status.status.recovery ? { id: status.status.recovery.operationId, running: true } : undefined;
|
|
227
227
|
}
|
|
228
228
|
if (syncPermissionDefaults(state, status.status?.permissionDefaults) && state.confirm?.type === "permission_default") stream({ text: permissionDefaultText(state.confirm), kind: "permission_default", peer: state.confirm.peer, tone: "attention" });
|
|
229
|
-
if (budget.budget)
|
|
229
|
+
if (budget.budget) {
|
|
230
|
+
// #285: the hub's note per peer (no quota, nothing read yet) rides next to the windows, so the peer detail says why a number is missing.
|
|
231
|
+
const notes = new Map<string, any>((Array.isArray(budget.peers) ? budget.peers : []).map((n: any) => [String(n?.peer), n] as const));
|
|
232
|
+
state.budget = Object.fromEntries([...new Set([...Object.keys(budget.budget), ...notes.keys()])].map(peer => [peer, { windows: [], ...budget.budget[peer], ...(notes.get(peer)?.note ? { note: String(notes.get(peer).note) } : {}) }]));
|
|
233
|
+
}
|
|
230
234
|
if (usageReplies) {
|
|
231
235
|
const [today, week, life, limits, task] = usageReplies, failed = usageReplies.find(reply => reply?.ok === false);
|
|
232
236
|
if (failed || !today?.usage || !week?.usage || !life?.usage || !limits?.limits) state.usage = { ...state.usage, problem: String(failed?.error ?? "usage is unavailable") };
|
|
@@ -243,6 +247,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
243
247
|
if (settings.ok && Array.isArray(settings.rows)) {
|
|
244
248
|
state.settingsRows = settings.rows; state.settingsKnown = true; state.settingsProblem = undefined;
|
|
245
249
|
state.settingsUndo = typeof settings.undo === "string" ? settings.undo : undefined;
|
|
250
|
+
const revisions = (settings.configEdit as { revisions?: unknown } | undefined)?.revisions;
|
|
251
|
+
const known = (settings.configEdit as { known?: unknown } | undefined)?.known;
|
|
252
|
+
state.configInstances = Array.isArray(known) ? known.map(String).filter(Boolean) : [];
|
|
253
|
+
if (revisions && typeof revisions === "object") state.configRevisions = Object.fromEntries(Object.entries(revisions).filter(([, value]) => typeof value === "string")) as Record<string, string>;
|
|
246
254
|
state.pendingRestart = Array.isArray(settings.pendingRestart) ? settings.pendingRestart : [];
|
|
247
255
|
state.retainedConfig = Array.isArray(settings.retainedConfig) ? settings.retainedConfig : [];
|
|
248
256
|
} else state.settingsProblem = String(settings.error ?? "settings are unavailable");
|
|
@@ -282,6 +290,26 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
282
290
|
} else state.settingsEdit = { ...edit, ...(action.undo ? { key: response.key, undoDigest: response.undoDigest } : {}), phase: "reason", text: "", lines: [...(Array.isArray(response.lines) ? response.lines.map(String) : []), ...(Array.isArray(response.tasks) ? response.tasks.flatMap((task: any) => [String(task.before?.join("\n") ?? ""), String(task.after?.join("\n") ?? "")]) : []), ...(response.hidden ? [`${response.hidden} more open tasks are not shown`] : [])], ...(typeof response.confirm === "string" ? { confirm: response.confirm } : {}) };
|
|
283
291
|
draw(); return;
|
|
284
292
|
}
|
|
293
|
+
if (action.type === "config_preview") {
|
|
294
|
+
const response = await client.request({ t: "config_edit_preview", edit: action.edit }, 5000);
|
|
295
|
+
const form = state.configForm;
|
|
296
|
+
// A reply for a form that was cancelled or replaced is dropped: it never opens a confirmation of its own.
|
|
297
|
+
if (!active || state.mode !== "panels" || !form || form.token !== action.token) return; // every form has its own token and one outstanding preview
|
|
298
|
+
if (response.ok === false) { state.configForm = undefined; notice(String(response.error ?? "config preview refused")); }
|
|
299
|
+
else state.configForm = { ...form, phase: "reason", text: "", lines: (Array.isArray(response.lines) ? response.lines : []).map(String).slice(0, 40),
|
|
300
|
+
...(typeof response.confirm === "string" ? { typed: response.confirm, warning: typeof response.warning === "string" ? response.warning : undefined } : {}) };
|
|
301
|
+
draw(); return;
|
|
302
|
+
}
|
|
303
|
+
if (action.type === "config_write") {
|
|
304
|
+
const result = await client.request({ t: "config_edit_apply", edit: action.edit, reason: action.reason, ...(action.confirm ? { confirm: action.confirm } : {}) }, 35_000);
|
|
305
|
+
if (!active) return;
|
|
306
|
+
if (result.ok === false) notice(String(result.error ?? "config write refused"));
|
|
307
|
+
else {
|
|
308
|
+
const text = [String(result.text ?? "config saved"), ...(Array.isArray(result.restartPending) && result.restartPending.length ? [`restart pending: ${result.restartPending.join(", ")}`] : [])].join("; ");
|
|
309
|
+
stream({ text, kind: "settings" }); notice(text, "info");
|
|
310
|
+
}
|
|
311
|
+
await refresh(); draw(); return;
|
|
312
|
+
}
|
|
285
313
|
if (action.type === "settings_write") {
|
|
286
314
|
const result = await client.request({ t: action.undo ? "settings_undo" : "settings_set", key: action.key, ...(!action.undo ? { value: action.value } : { undoDigest: action.undoDigest }), reason: action.reason, ...(action.confirm ? { confirm: action.confirm } : {}) }, 35_000);
|
|
287
315
|
if (!active) return;
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -65,7 +65,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
65
65
|
const port = definitionOf(peer)?.hooks?.factsHook;
|
|
66
66
|
const payload: unknown = JSON.parse(stdin);
|
|
67
67
|
if (!port?.decode(nativeEvent, payload)) return undefined;
|
|
68
|
-
const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
|
|
68
|
+
const hub = await ControlClient.connect(stateDir, { role: "tools", peer, ...(nativeHookIdentity(stateDir, peer).nativeLaunchId ? { launchId: nativeHookIdentity(stateDir, peer).nativeLaunchId } : {}) }, timeoutMs);
|
|
69
69
|
try {
|
|
70
70
|
const reply = await hub.request({ t: "native_facts", event: nativeEvent, payload, ...nativeHookIdentity(stateDir, peer) }, timeoutMs);
|
|
71
71
|
const output = reply.ok ? port.encode(nativeEvent, typeof reply.text === "string" ? reply.text : undefined) : undefined;
|
|
@@ -74,7 +74,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
74
74
|
}
|
|
75
75
|
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 };
|
|
76
76
|
const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
|
|
77
|
-
const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
|
|
77
|
+
const hub = await ControlClient.connect(stateDir, { role: "tools", peer, ...(nativeHookIdentity(stateDir, peer).nativeLaunchId ? { launchId: nativeHookIdentity(stateDir, peer).nativeLaunchId } : {}) }, timeoutMs);
|
|
78
78
|
try {
|
|
79
79
|
const res = await hub.request({
|
|
80
80
|
t: "facts",
|
|
@@ -90,10 +90,14 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
90
90
|
if (phase !== "pre" || !res?.ok) return undefined;
|
|
91
91
|
const permission = isPermissionMode(res.permission) ? res.permission : "ask";
|
|
92
92
|
const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot, res.protectInstructionFiles === true);
|
|
93
|
-
|
|
93
|
+
// Always-ask (#636): the hub classed this call (publish, external-write, destroy, install). `ask` wins over every allow, never-ask included.
|
|
94
|
+
// A present value that is not an empty list asks, even when malformed: the hook never turns an unreadable classification into an allow.
|
|
95
|
+
const classed = res.actionClasses !== undefined && !(Array.isArray(res.actionClasses) && res.actionClasses.length === 0);
|
|
96
|
+
const classNames = Array.isArray(res.actionClasses) ? res.actionClasses.filter((key: unknown): key is string => typeof key === "string" && /^[a-z-]{1,32}$/.test(key)) : [];
|
|
97
|
+
const allow = !classed && input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
|
|
94
98
|
const text = typeof res.text === "string" && res.text ? res.text : undefined;
|
|
95
|
-
if (!allow && !text) return undefined;
|
|
96
|
-
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(allow ? { permissionDecision: "allow" } : {}) } });
|
|
99
|
+
if (!allow && !classed && !text) return undefined;
|
|
100
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(classed ? { permissionDecision: "ask", permissionDecisionReason: `agent-hub always-ask${classNames.length ? `: ${classNames.join(", ")}` : ""}` } : allow ? { permissionDecision: "allow" } : {}) } });
|
|
97
101
|
} finally {
|
|
98
102
|
hub.close();
|
|
99
103
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -40,7 +40,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
40
40
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
41
41
|
["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
|
|
42
42
|
["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
|
|
43
|
-
["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory
|
|
43
|
+
["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory library this project uses"],
|
|
44
|
+
["ahub doctor --stale-anchors [--json]", "read-only bounded report of public memory symbol anchors at current HEAD; private or unavailable evidence stays unknown"],
|
|
44
45
|
["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
|
|
45
46
|
["ahub agents [--json]", "show each gated agent CLI's installed version and verdict (verified, in-line, trusted, refused or unknown)"],
|
|
46
47
|
["ahub agents trust <id> <version> [--revoke]", "accept an untested agent CLI version on this machine (person only; runtime proofs still run)"],
|
|
@@ -73,6 +74,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
73
74
|
["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
|
|
74
75
|
["ahub settings [list] [--json] | get <key> [--json]", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
|
|
75
76
|
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
|
|
77
|
+
["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
|
|
76
78
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
77
79
|
["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
|
|
78
80
|
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
|
|
@@ -83,26 +85,27 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
83
85
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
84
86
|
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for"],
|
|
85
87
|
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
86
|
-
["ahub backlog list | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only:
|
|
88
|
+
["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
|
|
87
89
|
["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
|
|
88
90
|
["ahub backlog split <id> <revision> <parts-json> <reason...>", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
|
|
89
91
|
["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
|
|
90
92
|
["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
|
|
93
|
+
["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "person only: what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review"],
|
|
91
94
|
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
92
95
|
["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
|
93
|
-
["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or
|
|
96
|
+
["ahub task assign <id> <peer> [--role owner|reviewer|planner] [--revision N]", "name the owner (default), reviewer or person-only planner; a task that still waits reserves its owner; a peer that cannot take it is refused with the reason; --revision refuses stale console actions"],
|
|
94
97
|
["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
|
|
95
98
|
["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"],
|
|
96
99
|
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
97
100
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>]", "give a review verdict on a task; the person can do the same in ahub console: Tasks, select the task, r, then a (approve) or c (request changes, with a note)"],
|
|
98
101
|
["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
|
|
99
|
-
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
|
102
|
+
["ahub route explain <id>", "why a task went where it went, its reserved owner and the planner walk included"],
|
|
100
103
|
["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
|
|
101
104
|
["ahub ask [--remember] <question...>", "answer from the task board, shared memory and this run's log, with the ids it rests on"],
|
|
102
105
|
["ahub remember <text...>", "save a note to the memory all agents share"],
|
|
103
106
|
]],
|
|
104
107
|
["Budget", [
|
|
105
|
-
["ahub budget [--json] [--full] [--color=auto|always|never]", "quota windows per peer, and who is paused until when"],
|
|
108
|
+
["ahub budget [--json] [--full] [--color=auto|always|never]", "quota windows per peer with each number's kind (native, count, manual) and source, why a peer shows no number, and who is paused until when"],
|
|
106
109
|
["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
|
|
107
110
|
["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
|
|
108
111
|
["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
|
|
@@ -128,7 +131,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
128
131
|
["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
|
|
129
132
|
["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
|
|
130
133
|
["ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] [--json]", "the hub's own memory for this project: decisions, contracts, fails and findings, short- and long-term (docs/memory.md)"],
|
|
131
|
-
["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood"],
|
|
134
|
+
["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--history | --as-of <iso>] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood; superseded and expired entries are hidden unless a person asks for --history or --as-of"],
|
|
132
135
|
["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
|
|
133
136
|
["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
|
|
134
137
|
["ahub memory import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
|
|
@@ -143,7 +146,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
143
146
|
]],
|
|
144
147
|
["Dashboard", [
|
|
145
148
|
["ahub ui [--no-open]", "open the local dashboard with configured attention character (or print its one-time link)"],
|
|
146
|
-
["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing and
|
|
149
|
+
["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing, start settings and structural config"],
|
|
147
150
|
["ahub ui --all [--no-open]", "open the unified project dashboard"],
|
|
148
151
|
["ahub ui --all --stop", "stop only the dashboard manager"],
|
|
149
152
|
["ahub manager", "internal: the dashboard manager process that ahub ui --all starts"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
|
|
|
17
17
|
if (command === "sdlc" && (args[0] === "authorize-release" || args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
|
|
18
18
|
if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
|
|
19
19
|
const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
|
|
20
|
-
return ["task", "role", "goal"].includes(command)
|
|
20
|
+
return ["task", "role", "goal", "deliverables"].includes(command)
|
|
21
21
|
|| (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
|
|
22
22
|
|| (command === "memory" && sub !== "graph")
|
|
23
23
|
|| (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
|
|
@@ -68,6 +68,8 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
|
|
|
68
68
|
if (["assign", "escalate"].includes(args[0] ?? "")) return "conductor";
|
|
69
69
|
return "console";
|
|
70
70
|
}
|
|
71
|
+
// #317: the deliverable list is the person's view of the board (it can name files); an agent reads its own tasks through hub_task_show.
|
|
72
|
+
if (command === "deliverables") return "console";
|
|
71
73
|
if (command === "route") return args[0] === "explain" ? "allowed" : "console";
|
|
72
74
|
// #691: reading agent CLI versions is harmless; trusting an untested version on this machine is a person's.
|
|
73
75
|
if (command === "agents") return args.every(arg => arg === "--json") ? "allowed" : "console";
|
package/src/cli/launch.ts
CHANGED
|
@@ -30,10 +30,14 @@ export function instanceLaunchArguments(command: string, raw: readonly string[],
|
|
|
30
30
|
const declaration = instances && Object.hasOwn(instances, id) ? instances[id] : undefined;
|
|
31
31
|
if (!declaration || declaration.kind !== command || !definitionOf(id, instances)) throw new Error("--as must name a declared instance of this command's native kind");
|
|
32
32
|
// #223: a headless reviewer is started by the hub, never from this terminal.
|
|
33
|
-
|
|
33
|
+
const runtime = definitionOf(id, instances), base = definitionOf(command);
|
|
34
|
+
hubStarted = runtime === base?.reviewerVariant || runtime === base?.headlessVariant;
|
|
34
35
|
instance = id;
|
|
35
36
|
}
|
|
36
|
-
if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run"))
|
|
37
|
+
if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run")) {
|
|
38
|
+
const writer = definitionOf(instance!, instances) === definitionOf(command)?.headlessVariant;
|
|
39
|
+
throw new Error(`${instance} is a headless ${writer ? "implementer the hub runs per task" : "reviewer the hub runs per review"}: ahub ${command} --as ${instance} takes no launch arguments`);
|
|
40
|
+
}
|
|
37
41
|
return { ...(instance ? { instance } : {}), args, ...(hubStarted ? { hubStarted: true as const } : {}) };
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -108,6 +112,8 @@ export interface ClaudeLaunchRecord extends Record<string, unknown> {
|
|
|
108
112
|
instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
|
|
109
113
|
/** A claude-<name> instance (#450 I3); absent for the builtin claude peer, whose file keeps its original name. */
|
|
110
114
|
peer?: string;
|
|
115
|
+
/** Validated terminal instance root; absent for legacy/canonical launches. */
|
|
116
|
+
executionCwd?: string;
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
|
|
@@ -172,10 +178,10 @@ export function recordClaudeLaunch(stateDir: string, record: ClaudeLaunchRecord)
|
|
|
172
178
|
|
|
173
179
|
function sameClaudeLaunch(left: ClaudeLaunchRecord, right: ClaudeLaunchRecord): boolean {
|
|
174
180
|
return left.instanceId === right.instanceId && left.launchId === right.launchId && left.settingsFile === right.settingsFile
|
|
175
|
-
&& left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature;
|
|
181
|
+
&& left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature && left.executionCwd === right.executionCwd;
|
|
176
182
|
}
|
|
177
183
|
function nativeAttestationPath(stateDir: string, launch: ClaudeLaunchRecord): string {
|
|
178
|
-
const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature])).digest("hex");
|
|
184
|
+
const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature, ...(launch.executionCwd ? [launch.executionCwd] : [])])).digest("hex");
|
|
179
185
|
return join(stateDir, `claude-native-${hash}.json`);
|
|
180
186
|
}
|
|
181
187
|
function privateClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): { file: string; stat: ReturnType<typeof lstatSync>; record: ClaudeLaunchRecord } | undefined {
|