@staix/agent-hub 0.15.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 +25 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +13 -1
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +13 -4
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +116 -30
- package/docs/quickstart.md +8 -4
- package/docs/security.md +80 -18
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
- 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 +26 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2085 -1702
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +2 -2
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +1 -1
- package/src/cli/console-state.ts +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +250 -49
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- 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/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +353 -64
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -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 +100 -18
package/src/adapters/acp.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface PermissionRequest {
|
|
|
31
31
|
/** What the host's always-ask gate says about one request (#636): the approval text it must show, and a class-budget choice when one applies. */
|
|
32
32
|
export interface ActionGateVerdict {
|
|
33
33
|
title: string;
|
|
34
|
+
/** The person cannot see the complete command: no allow option or class-budget grant is valid. */
|
|
35
|
+
denyOnly?: true;
|
|
34
36
|
/** A person's explicit choice to approve the classes for the rest of the session's budget (`label` names them); never an ordinary allow. */
|
|
35
37
|
budget?: { label: string; approve(): boolean };
|
|
36
38
|
}
|
|
@@ -687,7 +689,7 @@ export class AcpPeer extends BasePeer {
|
|
|
687
689
|
let gate: ActionGateVerdict | undefined;
|
|
688
690
|
try {
|
|
689
691
|
gate = rawInput === undefined || !this.sessionId ? undefined : this.opts.actionGate?.({ names: [call.title, announced, canonical].filter((name): name is string => typeof name === "string"), ...(kind ? { kind } : {}), input: rawInput }, this.sessionId);
|
|
690
|
-
} catch { gate = { title: "Action approval: the always-ask check failed;
|
|
692
|
+
} catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; } // a failing gate asks, never allows
|
|
691
693
|
if (once && identity && !gate) {
|
|
692
694
|
this.opts.log?.(`permission auto-approved for ${this.id}: ${identity}`); // the name only: arguments may quote a PII turn
|
|
693
695
|
this.emitActivity("ask", `${String(msg.id)}: ${activityText(announced ?? call.title)} (automatic)`);
|
|
@@ -708,8 +710,9 @@ export class AcpPeer extends BasePeer {
|
|
|
708
710
|
const title: string = gate ? gate.title : raw === undefined ? `${named ?? "tool call"} (payload not reported by the agent)` : `${named ?? "tool call"}${input}`;
|
|
709
711
|
this.emitActivity("ask", `${String(msg.id)}: ${activityText(title)}; answer in ahub console`);
|
|
710
712
|
let options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => o.kind !== "allow_always" || !gate && !this.nativePolicy?.lockedReadOnly && raw !== undefined && !cut);
|
|
713
|
+
if (gate?.denyOnly) options = options.filter(o => o.kind === "reject_once" || o.kind === "reject_always");
|
|
711
714
|
// The class-budget choice is the hub's own option: answered to the agent as its allow_once once the ledger has recorded the grant.
|
|
712
|
-
const budgetOption: PermissionOption | undefined = gate?.budget && once ? { optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" } : undefined;
|
|
715
|
+
const budgetOption: PermissionOption | undefined = !gate?.denyOnly && gate?.budget && once ? { optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" } : undefined;
|
|
713
716
|
if (budgetOption) options = [...options, budgetOption];
|
|
714
717
|
// Only a bare tool name travels on its own (a desktop notice shows it); anything prose-like stays in the title.
|
|
715
718
|
const tool = typeof named === "string" && /^[\w.:@-]{1,80}$/.test(named) ? named : undefined;
|
|
@@ -6,6 +6,7 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
6
6
|
import { codexContext, type ContextReading } from "../hub/context-window.ts";
|
|
7
7
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
8
|
import type { ContextReader } from "./registry-types.ts";
|
|
9
|
+
import { ACTION_BUDGET_OPTION, type ActionGateVerdict, type PermissionRequest } from "./acp.ts";
|
|
9
10
|
|
|
10
11
|
const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
|
|
11
12
|
|
|
@@ -15,6 +16,9 @@ export interface CodexOptions {
|
|
|
15
16
|
/** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
|
|
16
17
|
proxyPort: number;
|
|
17
18
|
permissionMode?: PermissionMode;
|
|
19
|
+
/** Host-only always-ask gate, shared with ACP; an absent verdict preserves native handling. */
|
|
20
|
+
actionGate?: (call: { names: readonly string[]; kind?: string; input: unknown }, session: string) => ActionGateVerdict | undefined;
|
|
21
|
+
onPermission?: (request: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
|
|
18
22
|
/** Port for the spawned `codex app-server`. Ignored when `upstreamUrl` is set. */
|
|
19
23
|
appPort: number;
|
|
20
24
|
/** Attach to an already running app-server instead of spawning one (tests). */
|
|
@@ -65,6 +69,7 @@ interface Link {
|
|
|
65
69
|
tui: ServerWebSocket<Link>;
|
|
66
70
|
up: WebSocket;
|
|
67
71
|
backlog: string[]; // TUI frames that arrived before the upstream socket opened
|
|
72
|
+
approvalIds: Set<string | number>; // intercepted native ids stay tombstoned for this socket lifetime
|
|
68
73
|
tracked: Map<string | number, string>; // request id -> method, for thread/start and thread/resume
|
|
69
74
|
}
|
|
70
75
|
|
|
@@ -78,6 +83,8 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
|
|
|
78
83
|
*/
|
|
79
84
|
export class CodexPeer extends BasePeer {
|
|
80
85
|
private permissionMode: PermissionMode;
|
|
86
|
+
private readonly actionWaits = new Map<AbortController, { link: Link; turn: string | undefined }>();
|
|
87
|
+
get actionGated(): boolean { return this.opts.actionGate !== undefined; }
|
|
81
88
|
private readonly nativeApprovalPolicies = new Map<string, unknown>();
|
|
82
89
|
private readonly overriddenApprovals = new Set<string>();
|
|
83
90
|
private readonly restoringApprovals = new Map<string | number, string>();
|
|
@@ -200,6 +207,7 @@ export class CodexPeer extends BasePeer {
|
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
async stop(): Promise<void> {
|
|
210
|
+
this.abortActionWaits();
|
|
203
211
|
this.server?.stop(true);
|
|
204
212
|
this.claimedTui?.tui.close(1001, "hub shutting down");
|
|
205
213
|
const proc = this.proc;
|
|
@@ -353,6 +361,7 @@ export class CodexPeer extends BasePeer {
|
|
|
353
361
|
|
|
354
362
|
/** A silent turn is interrupted before the peer is declared idle, or the next turn/start would land inside it. */
|
|
355
363
|
protected override onWatchdog(): void {
|
|
364
|
+
this.abortActionWaits();
|
|
356
365
|
this.deliveryEpoch++;
|
|
357
366
|
const known = new Set([...this.activeTurns, ...this.turnDeliveries.keys()].filter(id => !id.startsWith("unknown:")));
|
|
358
367
|
const pendingStarts = [...this.pending.values()].filter(pending => pending.kind === "deliver");
|
|
@@ -379,6 +388,7 @@ export class CodexPeer extends BasePeer {
|
|
|
379
388
|
}
|
|
380
389
|
|
|
381
390
|
private retireTurn(turnId: string): void {
|
|
391
|
+
this.abortActionWaits(undefined, turnId);
|
|
382
392
|
this.retiredTurns.add(turnId);
|
|
383
393
|
if (this.link?.up.readyState === WebSocket.OPEN) this.link.up.send(JSON.stringify({ method: "turn/interrupt", id: this.nextId--, params: { threadId: this.threadId, turnId } }));
|
|
384
394
|
}
|
|
@@ -445,7 +455,7 @@ export class CodexPeer extends BasePeer {
|
|
|
445
455
|
|
|
446
456
|
private attach(tui: ServerWebSocket<Link>, upstream: string): void {
|
|
447
457
|
const up = new WebSocket(upstream);
|
|
448
|
-
const link: Link = { tui, up, backlog: [], tracked: new Map(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
|
|
458
|
+
const link: Link = { tui, up, backlog: [], tracked: new Map(), approvalIds: new Set(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
|
|
449
459
|
this.claimedTui = link;
|
|
450
460
|
tui.data = link;
|
|
451
461
|
up.onopen = () => {
|
|
@@ -456,6 +466,7 @@ export class CodexPeer extends BasePeer {
|
|
|
456
466
|
}
|
|
457
467
|
|
|
458
468
|
private detach(link: Link): void {
|
|
469
|
+
this.abortActionWaits(link);
|
|
459
470
|
link.up.close();
|
|
460
471
|
if (this.claimedTui === link) this.claimedTui = undefined;
|
|
461
472
|
if (this.link !== link) return;
|
|
@@ -539,6 +550,8 @@ export class CodexPeer extends BasePeer {
|
|
|
539
550
|
|
|
540
551
|
private fromTui(link: Link, raw: string): void {
|
|
541
552
|
const msg = parse(raw);
|
|
553
|
+
if (msg?.id !== undefined && !msg.method && link.approvalIds.has(msg.id)) return;
|
|
554
|
+
if (msg?.method === "turn/interrupt" && link === this.link) this.abortActionWaits(link, typeof msg.params?.turnId === "string" ? msg.params.turnId : undefined);
|
|
542
555
|
if (msg?.method === "turn/start" && msg.params && link === this.link) {
|
|
543
556
|
const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
|
|
544
557
|
// A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
|
|
@@ -561,6 +574,7 @@ export class CodexPeer extends BasePeer {
|
|
|
561
574
|
if (this.state === "busy" && !(typeof nativeId === "string" && this.retiredTurns.has(nativeId))) this.touch();
|
|
562
575
|
if (!msg) return void link.tui.send(raw);
|
|
563
576
|
|
|
577
|
+
if (this.interceptActionApproval(link, msg)) return;
|
|
564
578
|
if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
|
|
565
579
|
if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
|
|
566
580
|
const p = this.pending.get(msg.id);
|
|
@@ -583,8 +597,61 @@ export class CodexPeer extends BasePeer {
|
|
|
583
597
|
link.tui.send(raw);
|
|
584
598
|
}
|
|
585
599
|
|
|
600
|
+
private abortActionWaits(link?: Link, turn?: string): void {
|
|
601
|
+
for (const [controller, wait] of this.actionWaits) if ((!link || wait.link === link) && (!turn || wait.turn === turn)) controller.abort();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Only the two native command schemas verified on 0.163.0. Missing payloads keep their native policy. */
|
|
605
|
+
private interceptActionApproval(link: Link, msg: any): boolean {
|
|
606
|
+
if (!this.opts.actionGate || !(typeof msg.id === "string" || Number.isSafeInteger(msg.id))) return false;
|
|
607
|
+
const legacy = msg.method === "execCommandApproval", params = msg.params;
|
|
608
|
+
if (!legacy && msg.method !== "item/commandExecution/requestApproval") return false;
|
|
609
|
+
if (!params || typeof params !== "object") return false;
|
|
610
|
+
if (link.approvalIds.has(msg.id)) return true; // one intercepted callback has exactly one host answer
|
|
611
|
+
if (legacy ? !Array.isArray(params.command) || !params.command.length || !params.command.every((word: unknown) => typeof word === "string")
|
|
612
|
+
: typeof params.command !== "string" || !params.command.length || params.kind != null && params.kind !== "command") return false;
|
|
613
|
+
const thread = legacy ? params.conversationId : params.threadId;
|
|
614
|
+
let gate: ActionGateVerdict | undefined;
|
|
615
|
+
try { gate = this.opts.actionGate({ names: ["shell"], kind: "execute", input: { command: params.command, cwd: params.cwd } }, typeof thread === "string" ? thread : ""); }
|
|
616
|
+
catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
|
|
617
|
+
if (!gate) return false;
|
|
618
|
+
const verdict = gate;
|
|
619
|
+
// ponytail: retain at most 1024 intercepted ids per socket, never evict a tombstone and accept a late native answer.
|
|
620
|
+
// Reattach is the upgrade path after this finite lifetime cap; closing both sides fails closed.
|
|
621
|
+
if (link.approvalIds.size >= 1024) { this.abortActionWaits(link); link.up.close(); link.tui.close(1008, "Codex action approval id capacity reached; reattach"); return true; }
|
|
622
|
+
link.approvalIds.add(msg.id);
|
|
623
|
+
const named = [...this.activeTurns].filter(turn => !turn.startsWith("unknown:") && !this.retiredTurns.has(turn));
|
|
624
|
+
const turn = legacy ? named.length === 1 ? named[0] : undefined : typeof params.turnId === "string" ? params.turnId : undefined;
|
|
625
|
+
const epoch = this.contextEpoch, controller = new AbortController();
|
|
626
|
+
const live = () => !controller.signal.aborted && link === this.link && epoch === this.contextEpoch && thread === this.threadId &&
|
|
627
|
+
typeof turn === "string" && this.activeTurns.has(turn) && !this.retiredTurns.has(turn) && !this.unidentifiedCancellation && this.state === "busy" &&
|
|
628
|
+
link.tui.readyState === WebSocket.OPEN && link.up.readyState === WebSocket.OPEN;
|
|
629
|
+
const answer = (allow: boolean) => { if (link.up.readyState === WebSocket.OPEN) link.up.send(JSON.stringify({ id: msg.id, result: { decision: legacy ? allow ? "approved" : "abort" : allow ? "accept" : "decline" } })); };
|
|
630
|
+
if (!live()) { answer(false); return true; } // no invented native turn binding, even when the command is classed
|
|
631
|
+
this.actionWaits.set(controller, { link, turn });
|
|
632
|
+
const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
|
|
633
|
+
if (verdict.denyOnly) options.splice(0, 1); // keep only denial, even if a broken callback later returns allow
|
|
634
|
+
if (!verdict.denyOnly && verdict.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${verdict.budget.label} for this session`, kind: "allow_once" });
|
|
635
|
+
const alive = setInterval(() => { if (live()) this.touch(); }, Math.max(10, Math.min(30_000, Math.floor(this.watchdogMs / 3))));
|
|
636
|
+
void (async () => {
|
|
637
|
+
let picked: string | undefined;
|
|
638
|
+
try {
|
|
639
|
+
const cancelled = new Promise<undefined>(resolve => controller.signal.addEventListener("abort", () => resolve(undefined), { once: true }));
|
|
640
|
+
picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title: verdict.title, tool: "shell", kind: "execute", classed: true, options }, controller.signal), cancelled]);
|
|
641
|
+
} catch { /* a broken or missing approval handler denies */ }
|
|
642
|
+
finally { clearInterval(alive); this.actionWaits.delete(controller); }
|
|
643
|
+
let allow = !verdict.denyOnly && live() && picked === "allow";
|
|
644
|
+
if (!verdict.denyOnly && live() && verdict.budget && picked === ACTION_BUDGET_OPTION) {
|
|
645
|
+
try { allow = verdict.budget.approve() && live(); } catch { allow = false; }
|
|
646
|
+
}
|
|
647
|
+
answer(allow);
|
|
648
|
+
})();
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
|
|
586
652
|
private adopt(link: Link, threadId: unknown, fresh = false): void {
|
|
587
653
|
if (typeof threadId !== "string" || !threadId || link !== this.claimedTui || link.tui.readyState !== WebSocket.OPEN || link.up.readyState !== WebSocket.OPEN) return;
|
|
654
|
+
this.abortActionWaits();
|
|
588
655
|
this.abandonSteers("thread changed");
|
|
589
656
|
for (const [id, pending] of this.pending) if (pending.kind === "deliver") {
|
|
590
657
|
pending.invalidate?.("Codex native thread changed before acceptance was confirmed");
|
|
@@ -699,6 +766,7 @@ export class CodexPeer extends BasePeer {
|
|
|
699
766
|
if (item.phase !== "commentary" && text.trim()) this.lastAnswer = text.trim();
|
|
700
767
|
} else if (method === "turn/completed") {
|
|
701
768
|
const nativeTurn = params.turn?.id;
|
|
769
|
+
this.abortActionWaits(link, typeof nativeTurn === "string" ? nativeTurn : undefined);
|
|
702
770
|
// A watchdog or a newer turn has invalidated this completion. Unknown-id legacy starts retain their fallback.
|
|
703
771
|
if (!this.activeTurns.size || typeof nativeTurn === "string" && !this.activeTurns.has(nativeTurn) && ![...this.activeTurns].some(id => id.startsWith("unknown:"))) return;
|
|
704
772
|
this.activeTurns.delete(nativeTurn);
|
package/src/adapters/compat.ts
CHANGED
|
@@ -31,8 +31,8 @@ export const COMPAT = {
|
|
|
31
31
|
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309"], gate: "contract", probe: ["claude", "--version"],
|
|
32
32
|
parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
|
|
33
33
|
trustNote: "The native prompt-tool allow, deny, MCP error/disconnect and hook-failure fallthrough have an isolated receipt. No dontAsk/bypass fallback exists; another version's permission behavior is unverified." },
|
|
34
|
-
// 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
|
|
35
|
-
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
34
|
+
// 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md
|
|
35
|
+
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
36
36
|
parse: out => plain(out.trim().replace(/^codex-cli /, "")),
|
|
37
37
|
trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md)." },
|
|
38
38
|
// Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md
|
|
@@ -149,7 +149,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
149
149
|
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
150
150
|
permission: { grants: { ...hubGrants } },
|
|
151
151
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
|
|
152
|
-
turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
152
|
+
turnEnd: "adapter", budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
153
153
|
routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
|
|
154
154
|
cli: { command: "pi", entry: { lifecycleAdmission: false, execute: runPiEntry }, preview: previewPi, usage: [["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"]], launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
155
155
|
},
|
|
@@ -162,7 +162,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
162
162
|
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
163
163
|
permission: { grants: { ...hubGrants } },
|
|
164
164
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
|
|
165
|
-
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
165
|
+
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
166
166
|
routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
|
|
167
167
|
cli: { command: "local", entry: { lifecycleAdmission: false, execute: runLocalEntry }, usage: [["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"]], launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
168
168
|
},
|
|
@@ -243,7 +243,7 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly LegacyAdapterDefinition[
|
|
|
243
243
|
identity: { mcp: "env" },
|
|
244
244
|
permission: { modes: {}, grants: { ...hubGrants } },
|
|
245
245
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "pi", launchRecord: true },
|
|
246
|
-
turnEnd: "adapter", budget: { account: "pi" },
|
|
246
|
+
turnEnd: "adapter", budget: { account: "pi", noQuotaReason: "on-prem backends have no provider quota" },
|
|
247
247
|
cli: { command: "pi", startWords: form => ["pi", "--mode", form] }, display: palette("#c79936"),
|
|
248
248
|
},
|
|
249
249
|
{
|
|
@@ -235,7 +235,7 @@ export class LocalPeer extends BasePeer {
|
|
|
235
235
|
): Promise<string> {
|
|
236
236
|
const { maxSteps = 30 } = this.opts;
|
|
237
237
|
const turnSignal = this.abort!.signal;
|
|
238
|
-
//
|
|
238
|
+
// Local capture preserves the host privacy fence: nothing of a PII turn is recorded.
|
|
239
239
|
const capture = policy?.pii ? undefined : this.opts.capture;
|
|
240
240
|
// Unknown gateway state is never permission to send PII.
|
|
241
241
|
if (policy?.pii && !(await this.opts.omni.onCampus())) {
|
|
@@ -245,6 +245,7 @@ export class LocalPeer extends BasePeer {
|
|
|
245
245
|
this.routePii = !!policy?.pii;
|
|
246
246
|
const ctx = this.toolContext(envs, turnSignal, !!policy?.pii, reply);
|
|
247
247
|
let usedTools = false;
|
|
248
|
+
let captureInvocation = 0;
|
|
248
249
|
for (let step = 0; step < maxSteps; step++) {
|
|
249
250
|
this.elide(msgs);
|
|
250
251
|
const res = await this.call(msgs, policy, envs);
|
|
@@ -280,7 +281,7 @@ export class LocalPeer extends BasePeer {
|
|
|
280
281
|
if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
|
|
281
282
|
this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
282
283
|
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
283
|
-
if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
284
|
+
if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
284
285
|
}
|
|
285
286
|
} finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
|
|
286
287
|
}
|
|
@@ -293,7 +294,7 @@ export class LocalPeer extends BasePeer {
|
|
|
293
294
|
cwd: this.opts.cwd,
|
|
294
295
|
deny: this.opts.tools.deny,
|
|
295
296
|
readOnly: this.opts.tools.readOnly,
|
|
296
|
-
permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath) => {
|
|
297
|
+
permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath, input) => {
|
|
297
298
|
if (turnSignal.aborted) return "aborted";
|
|
298
299
|
const generation = this.turn;
|
|
299
300
|
let observed = false;
|
|
@@ -313,7 +314,7 @@ export class LocalPeer extends BasePeer {
|
|
|
313
314
|
const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
|
|
314
315
|
const onAbort = () => finish("aborted");
|
|
315
316
|
turnSignal.addEventListener("abort", onAbort, { once: true });
|
|
316
|
-
this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
317
|
+
this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath, input).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
317
318
|
});
|
|
318
319
|
if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
|
|
319
320
|
// Legacy permit callbacks can represent a person's denial without provenance. A true
|
|
@@ -270,7 +270,7 @@ export interface AdapterDefinition {
|
|
|
270
270
|
trace?: "route" | "backend";
|
|
271
271
|
backendLabel?: string;
|
|
272
272
|
};
|
|
273
|
-
budget?: { account: string; quotaExempt?: boolean; instrumented?: boolean; reader?: QuotaReader; context?: ContextReader; checkpoint?: boolean; resetSoftTokensOnResume?: boolean };
|
|
273
|
+
budget?: { account: string; quotaExempt?: boolean; /** Why this peer has no provider quota, shown by `ahub budget` and the dashboard (#285 AC6). */ noQuotaReason?: string; instrumented?: boolean; reader?: QuotaReader; context?: ContextReader; checkpoint?: boolean; resetSoftTokensOnResume?: boolean };
|
|
274
274
|
configuration?: {
|
|
275
275
|
schema?(ports: NativeConfigValidators): Record<string, NativeConfigCheck>;
|
|
276
276
|
routingSchema?(ports: NativeConfigValidators): Record<string, NativeConfigCheck>;
|
package/src/cli/console-state.ts
CHANGED
|
@@ -10,6 +10,7 @@ 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
12
|
import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
|
|
13
|
+
import { windowKind } from "../hub/window-kind.ts";
|
|
13
14
|
import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
|
|
14
15
|
|
|
15
16
|
export interface Approval {
|
|
@@ -334,8 +335,25 @@ function taskFields(detail: Record<string, unknown>): [string, unknown][] {
|
|
|
334
335
|
const at = fields.findIndex(([key]) => key === "plan");
|
|
335
336
|
return at < 0 ? [...fields, ...block] : [...fields.slice(0, at + 1), ...block, ...fields.slice(at + 1)];
|
|
336
337
|
}
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
};
|
|
339
357
|
const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
|
|
340
358
|
/** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
|
|
341
359
|
export function approvalFrom(msg: any): Approval {
|
|
@@ -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
|
@@ -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") };
|
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)"],
|
|
@@ -84,14 +85,15 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
84
85
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
85
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"],
|
|
86
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"],
|
|
87
|
-
["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:
|
|
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)"],
|
|
88
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"],
|
|
89
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"],
|
|
90
91
|
["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
|
|
91
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"],
|
|
92
94
|
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
93
95
|
["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
|
94
|
-
["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"],
|
|
95
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"],
|
|
96
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"],
|
|
97
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)"],
|
|
@@ -103,7 +105,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
103
105
|
["ahub remember <text...>", "save a note to the memory all agents share"],
|
|
104
106
|
]],
|
|
105
107
|
["Budget", [
|
|
106
|
-
["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"],
|
|
107
109
|
["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
|
|
108
110
|
["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
|
|
109
111
|
["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
|
|
@@ -129,7 +131,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
129
131
|
["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
|
|
130
132
|
["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
|
|
131
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)"],
|
|
132
|
-
["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"],
|
|
133
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"],
|
|
134
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"],
|
|
135
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"],
|
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/main.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import type { DeliverableRow } from "../hub/tasks.ts";
|
|
3
|
+
import { kimiSourceRow } from "../hub/budget-notes.ts";
|
|
2
4
|
import { hubGitSync, hubGitEnv } from "../hub/git.ts";
|
|
3
5
|
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "./sdlc-release-guard.ts";
|
|
4
6
|
import { validReleaseVersion } from "../hub/sdlc-release-authorization.ts";
|
|
@@ -46,7 +48,6 @@ import { inspectProject, startProject, stopProject, runProjectDaemon } from "../
|
|
|
46
48
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
47
49
|
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
48
50
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
49
|
-
import { MemoryClient } from "../memory/client.ts";
|
|
50
51
|
import { init, planInit } from "./init.ts";
|
|
51
52
|
import { launcherPreview } from "./preview.ts";
|
|
52
53
|
import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
@@ -75,8 +76,9 @@ import { parseSince, readEvents } from "../hub/events.ts";
|
|
|
75
76
|
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, MEMORY_MODES, readRuns, runSummary, sameSuite, type MemoryMode } from "../hub/bench.ts";
|
|
76
77
|
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
77
78
|
import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
|
|
78
|
-
import { goalCommand, memoryCommand } from "./memory.ts";
|
|
79
|
-
import {
|
|
79
|
+
import { goalCommand, memoryCommand, memoryStaleAnchorsCommand } from "./memory.ts";
|
|
80
|
+
import { formatMemoryStaleAnchors } from "../hub/memory-stale-anchors.ts";
|
|
81
|
+
import { AGENT_MEM_VERSION, assertAgentMemVersion, selectedMemoryProfile } from "../hub/memory-profile.ts";
|
|
80
82
|
import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
|
|
81
83
|
import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
82
84
|
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
@@ -1140,7 +1142,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1140
1142
|
const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
|
|
1141
1143
|
hub.close();
|
|
1142
1144
|
if (!res.ok) fail(res.error);
|
|
1143
|
-
const data = { budget: res.budget, gate: res.gate };
|
|
1145
|
+
const data = { budget: res.budget, ...(res.peers ? { peers: res.peers } : {}), gate: res.gate };
|
|
1144
1146
|
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
1145
1147
|
printOutput(renderBudget(data, options.columns, Date.now(), options.full), options.color);
|
|
1146
1148
|
},
|
|
@@ -1195,6 +1197,27 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1195
1197
|
console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
|
|
1196
1198
|
},
|
|
1197
1199
|
|
|
1200
|
+
deliverables: async () => {
|
|
1201
|
+
// #317: what each task delivered and what a reviewer approved, by id, hash and size (a path only where the PII screen allows it). Person only: the daemon refuses an agent.
|
|
1202
|
+
const options = outputOptions();
|
|
1203
|
+
const { one, rest } = takeFlags(args, ["--task"], []);
|
|
1204
|
+
const approved = rest.includes("--approved"), check = rest.includes("--check");
|
|
1205
|
+
const extra = rest.filter(arg => !["--json", "--full", "--approved", "--check"].includes(arg) && !arg.startsWith("--color="));
|
|
1206
|
+
const task = one["--task"] === undefined ? undefined : Number(one["--task"]);
|
|
1207
|
+
// takeFlags keeps the last of a repeated flag: for a command that selects a task that would silently drop one of two named tasks.
|
|
1208
|
+
if (args.filter(arg => arg === "--task").length > 1) fail("usage: ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never] (--task takes one id)");
|
|
1209
|
+
if (extra.length || (one["--task"] !== undefined && !/^[1-9]\d*$/.test(one["--task"])) || (task !== undefined && !Number.isSafeInteger(task))) fail("usage: ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]");
|
|
1210
|
+
const rows = JSON.parse(await taskOp("deliverables", { ...(task === undefined ? {} : { task }), ...(approved ? { approved: true } : {}), ...(check ? { check: true } : {}) })) as DeliverableRow[];
|
|
1211
|
+
if (options.json) return console.log(JSON.stringify(rows, null, 2));
|
|
1212
|
+
if (!rows.length) return console.log(task === undefined ? `no ${approved ? "approved " : ""}deliverables on the board` : `task #${task} has no ${approved ? "approved " : ""}deliverables`);
|
|
1213
|
+
for (const row of rows) {
|
|
1214
|
+
const flag = row.checked ? ({ changed: " (changed since review)", missing: " (a file is missing)", unknown: " (could not be measured)", unchanged: " (unchanged)" } as const)[row.result ?? "unknown"] : "";
|
|
1215
|
+
console.log(`${row.title} [${row.state}${flag}]`); // `title` is the hub's publicTitle: it already starts with `#<id>`
|
|
1216
|
+
for (const file of row.files) console.log(` ${file.id} ${file.path ?? "(private)"} ${file.format}${file.version ? ` ${file.version}` : ""} ${file.bytes} B sha256 ${file.sha256}${file.state ? ` ${file.state}` : ""}`);
|
|
1217
|
+
}
|
|
1218
|
+
if (!check) console.log("(hashes are the board's records; add --check to measure the files now)");
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1198
1221
|
board: async () => {
|
|
1199
1222
|
if (args[0] === "import") {
|
|
1200
1223
|
if (args[1] !== "github" || (args.length - 2) % 2 !== 0) fail("usage: ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]");
|
|
@@ -1380,8 +1403,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1380
1403
|
if (sub === "assign") {
|
|
1381
1404
|
const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
|
|
1382
1405
|
const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
|
|
1383
|
-
if (positional.length !== 2 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]");
|
|
1384
|
-
return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role
|
|
1406
|
+
if (positional.length !== 2 || !["owner", "reviewer", "planner"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer|planner] [--revision N]");
|
|
1407
|
+
return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role !== "owner" ? { role } : {}), ...(revision === undefined ? {} : { revision }) }));
|
|
1385
1408
|
}
|
|
1386
1409
|
if (sub === "unassign") {
|
|
1387
1410
|
const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
|
|
@@ -2101,6 +2124,12 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2101
2124
|
|
|
2102
2125
|
doctor: async () => {
|
|
2103
2126
|
const options = outputOptions();
|
|
2127
|
+
if (args.includes("--stale-anchors")) {
|
|
2128
|
+
if (args.some(arg => arg !== "--stale-anchors" && arg !== "--json")) fail("usage: ahub doctor --stale-anchors [--json]");
|
|
2129
|
+
const report = await memoryStaleAnchorsCommand(cwd, { stateDir });
|
|
2130
|
+
console.log(options.json ? JSON.stringify(report, null, 2) : formatMemoryStaleAnchors(report));
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2104
2133
|
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
|
|
2105
2134
|
const version = (bin: string) => {
|
|
2106
2135
|
const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
@@ -2128,6 +2157,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2128
2157
|
const { status } = await hub.request({ t: "status" });
|
|
2129
2158
|
const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
|
|
2130
2159
|
row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
|
|
2160
|
+
// #285 AC10: where Kimi's number comes from, and whether the hub can trust it right now. An older hub answers without `kimiSource`: no row, not a failure.
|
|
2161
|
+
const quota = await hub.request({ t: "budget" });
|
|
2162
|
+
const kimiRow = quota.ok ? kimiSourceRow(quota.kimiSource) : undefined;
|
|
2163
|
+
if (kimiRow) row(kimiRow.ok, "kimi usage source", kimiRow.detail);
|
|
2131
2164
|
const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
|
|
2132
2165
|
for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
|
|
2133
2166
|
const check = nativeDoctorRecoveryIdentity(peer);
|
|
@@ -2182,9 +2215,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2182
2215
|
row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
|
|
2183
2216
|
}
|
|
2184
2217
|
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
row(
|
|
2218
|
+
let memoryLibraryReady = false;
|
|
2219
|
+
try { assertAgentMemVersion({ name: "@staix/agent-mem", version: AGENT_MEM_VERSION }); memoryLibraryReady = true; } catch { /* report the failed exact pin without opening a store */ }
|
|
2220
|
+
row(memoryLibraryReady, "memory library", memoryLibraryReady ? `amem ${AGENT_MEM_VERSION}; in-process, no claude-mem worker` : "installed amem does not match the exact dependency pin");
|
|
2188
2221
|
const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
|
|
2189
2222
|
const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2190
2223
|
for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
|
|
@@ -2260,7 +2293,8 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2260
2293
|
op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
|
|
2261
2294
|
const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);
|
|
2262
2295
|
const role = one["--role"] ?? "owner";
|
|
2263
|
-
if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] | escalate <id>");
|
|
2296
|
+
if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer", "planner"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer|planner] | escalate <id>");
|
|
2297
|
+
if (role === "planner") fail("only the person may name a task planner");
|
|
2264
2298
|
input = { id: Number(positional[0]), ...(args[0] === "assign" ? { peer: positional[1], ...(role === "reviewer" ? { role } : {}) } : {}) };
|
|
2265
2299
|
} else if (cmd === "pause" || cmd === "resume") {
|
|
2266
2300
|
if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
|