@staix/agent-hub 0.28.0 → 0.30.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 +22 -0
- package/README.md +5 -3
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +4 -1
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +3 -0
- package/docs/design/adapters.md +56 -0
- package/docs/design/agent-hub-architecture.md +12 -0
- package/docs/design/bus.md +8 -0
- package/docs/design/task-board.md +19 -1
- package/docs/dogfood.md +13 -7
- package/docs/operations.md +177 -20
- package/docs/quickstart.md +18 -3
- package/docs/sdlc.md +2 -2
- package/docs/security.md +45 -4
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.29.0.md +59 -0
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +715 -45
- package/src/adapters/compat.ts +10 -10
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/local-worker.ts +22 -6
- package/src/adapters/pi.ts +92 -10
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/registry.ts +2 -0
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/console-state.ts +6 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +39 -0
- package/src/cli/output.ts +5 -0
- package/src/cli/recovery-package.ts +9 -0
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/board.ts +31 -5
- package/src/hub/bus.ts +4 -4
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +16 -1
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/daemon.ts +408 -66
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/hub-tools.ts +12 -6
- package/src/hub/manager.ts +9 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/routing.ts +9 -2
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +192 -25
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/capture.ts +1 -1
- package/src/memory/client.ts +5 -2
- package/src/pi/extension.ts +43 -3
- package/src/ui/index.html +133 -10
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin.ts +14 -3
package/src/adapters/compat.ts
CHANGED
|
@@ -29,11 +29,11 @@ export interface CompatPolicy {
|
|
|
29
29
|
|
|
30
30
|
const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
|
|
31
31
|
export const COMPAT = {
|
|
32
|
-
//
|
|
33
|
-
pi: { label: "Pi", verified: ["1.4.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
|
|
32
|
+
// Receipts: docs/verification/2026-10-26-pi-140-compat.md (1.4.0); docs/verification/2026-11-03-pi-150-compat.md (1.5.0)
|
|
33
|
+
pi: { label: "Pi", verified: ["1.4.0", "1.5.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
|
|
34
34
|
parse: out => plain(out.trim()),
|
|
35
|
-
trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
|
|
36
|
-
receipt: "docs/verification/2026-
|
|
35
|
+
trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
|
|
36
|
+
receipt: "docs/verification/2026-11-03-pi-150-compat.md" },
|
|
37
37
|
// Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; host-wrapper receipt below repeats with them.
|
|
38
38
|
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316"], gate: "contract", probe: ["claude", "--version"],
|
|
39
39
|
parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
|
|
@@ -53,14 +53,14 @@ export const COMPAT = {
|
|
|
53
53
|
parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
|
|
54
54
|
trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
|
|
55
55
|
receipt: "docs/verification/2026-10-26-jcode-0.99.1.md" },
|
|
56
|
-
agy: { label: "agy", verified: ["1.4.2", "1.4.7"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
|
|
56
|
+
agy: { label: "agy", verified: ["1.4.2", "1.4.7", "1.4.12"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
|
|
57
57
|
parse: out => plain(out.trim()),
|
|
58
|
-
trustNote: "Only 1.4.2 and 1.4.
|
|
59
|
-
receipt: "docs/verification/2026-
|
|
60
|
-
qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
58
|
+
trustNote: "Only 1.4.2, 1.4.7 and 1.4.12 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
|
|
59
|
+
receipt: "docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md" },
|
|
60
|
+
qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
61
61
|
parse: out => plain(out.trim()),
|
|
62
|
-
trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4; real model turns remain unverified. Another version may change those contracts.",
|
|
63
|
-
receipt: "docs/verification/2026-
|
|
62
|
+
trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4 and 0.26.5; real model turns remain unverified. Another version may change those contracts.",
|
|
63
|
+
receipt: "docs/verification/2026-11-03-qwen-0265-acp.md" },
|
|
64
64
|
// Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
|
|
65
65
|
// the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
|
|
66
66
|
"qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
@@ -119,7 +119,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
|
|
122
|
-
api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
122
|
+
api: 1, kind: "pi", title: "hub", titlePersistence: "native", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
123
123
|
roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
|
|
124
124
|
start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
|
|
125
125
|
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
@@ -132,7 +132,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
|
|
135
|
-
api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
135
|
+
api: 1, kind: "local", title: "hub", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
136
136
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
|
|
137
137
|
start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
|
|
138
138
|
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
@@ -5,8 +5,8 @@ import type { StaySwitchPolicy } from "../models/route/stage.ts";
|
|
|
5
5
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { MAX_HOP, remoteEnvelope, renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
|
|
8
|
-
import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
|
|
9
|
-
import { BasePeer } from "../hub/peers.ts";
|
|
8
|
+
import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools, SESSION_TITLE_TOOL } from "../hub/hub-tools.ts";
|
|
9
|
+
import { BasePeer, type SessionTitleResult } from "../hub/peers.ts";
|
|
10
10
|
import type { PublishResult } from "../hub/bus.ts";
|
|
11
11
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
12
12
|
import type { DocReadPort } from "../local/doc-read.ts";
|
|
@@ -22,6 +22,8 @@ export interface LocalOptions {
|
|
|
22
22
|
profile?: import("../hub/profiles.ts").ProfileSelection;
|
|
23
23
|
sdlcStages?: readonly string[];
|
|
24
24
|
sdlcCriteria?: boolean;
|
|
25
|
+
/** Host-owned conditional instruction, sampled once per delivered turn. */
|
|
26
|
+
titlePreface?: () => string | undefined;
|
|
25
27
|
cwd: string;
|
|
26
28
|
omni: OmniRoute;
|
|
27
29
|
/** In-process hub route id; legacy routes use the fixed model. */
|
|
@@ -82,6 +84,20 @@ export class LocalPeer extends BasePeer {
|
|
|
82
84
|
readonly deliveryOnly: boolean = true;
|
|
83
85
|
readonly hubOwned: boolean = true;
|
|
84
86
|
private readonly history: ChatMessage[] = [];
|
|
87
|
+
private title = "";
|
|
88
|
+
get sessionTitle(): string { return this.title; }
|
|
89
|
+
get titleSession(): string { return this.sessionId; }
|
|
90
|
+
async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
|
|
91
|
+
if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean") return { ok: false, reason: "invalid", error: "Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again." };
|
|
92
|
+
if (this.state === "offline") return { ok: false, reason: "invalid", error: "Local worker session is offline; start the peer and check ahub status before naming it." };
|
|
93
|
+
let admitted = false;
|
|
94
|
+
try { admitted = current?.() !== false; } catch { /* host admission failure is a refusal */ }
|
|
95
|
+
if (!admitted) return { ok: false, reason: "invalid", error: "Session title admission changed before the write; check ahub status and retry only in the current public session." };
|
|
96
|
+
if (onlyIfUnnamed && this.title) return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
|
|
97
|
+
this.title = title;
|
|
98
|
+
try { this.onDoing?.("title", title); } catch { /* observation failure cannot undo the saved local title */ }
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
85
101
|
private readonly sessionId = `agent-hub-local-${randomUUID()}`;
|
|
86
102
|
private readonly routes: HubRouteRuntime;
|
|
87
103
|
private readonly warnedLegacyRoutes = new Set<string>();
|
|
@@ -113,7 +129,7 @@ export class LocalPeer extends BasePeer {
|
|
|
113
129
|
await this.requireBudget(envs, "model_calls");
|
|
114
130
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
115
131
|
if (signal.aborted) throw new Error("turn cancelled before model request");
|
|
116
|
-
const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
132
|
+
const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
|
|
117
133
|
const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
|
|
118
134
|
this.recordUsage(result, model, task);
|
|
119
135
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
@@ -165,7 +181,7 @@ export class LocalPeer extends BasePeer {
|
|
|
165
181
|
if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
|
|
166
182
|
// The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
|
|
167
183
|
// never leave a tool call without its result (strict servers reject that history forever after).
|
|
168
|
-
const msgs: ChatMessage[] = [{ role: "user", content: renderDigest(envs, true) }];
|
|
184
|
+
const msgs: ChatMessage[] = [{ role: "user", content: [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n") }];
|
|
169
185
|
const progress = { sideEffects: 0, last: "" };
|
|
170
186
|
const requestedPolicy = this.opts.turnPolicy?.(envs);
|
|
171
187
|
const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
|
|
@@ -288,7 +304,7 @@ export class LocalPeer extends BasePeer {
|
|
|
288
304
|
const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
|
|
289
305
|
msgs.push(result); results.push(result);
|
|
290
306
|
if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
|
|
291
|
-
if (!remoteTurn) this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
307
|
+
if (!remoteTurn && name !== "hub_set_title") this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
292
308
|
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
293
309
|
// doc_read text is converted document content (a converter can surface hidden text), never memory evidence: not captured, whatever the turn.
|
|
294
310
|
// web_fetch text is a third party's page and its URL is the model's own request: neither is memory evidence either (#515b).
|
|
@@ -401,7 +417,7 @@ export class LocalPeer extends BasePeer {
|
|
|
401
417
|
}
|
|
402
418
|
const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
|
|
403
419
|
const task = policy?.task === undefined ? undefined : Number(policy.task);
|
|
404
|
-
const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
420
|
+
const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
|
|
405
421
|
const signal = this.abort!.signal;
|
|
406
422
|
const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
|
|
407
423
|
const routed = await this.hubCall(route, messages, policy, signal);
|
package/src/adapters/pi.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
6
|
-
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
7
|
-
import { BasePeer } from "../hub/peers.ts";
|
|
6
|
+
import { renderDigest, remoteEnvelope, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
7
|
+
import { BasePeer, type SessionTitleResult } from "../hub/peers.ts";
|
|
8
8
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
9
9
|
import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
|
|
10
10
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
@@ -41,6 +41,8 @@ export interface PiOptions {
|
|
|
41
41
|
model?: string;
|
|
42
42
|
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
|
|
43
43
|
preamble?: string;
|
|
44
|
+
/** Host-owned conditional instruction, sampled once per delivered turn. */
|
|
45
|
+
titlePreface?: () => string | undefined;
|
|
44
46
|
selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
|
|
45
47
|
/** Atomic task/run admission immediately before provider requests or tool execution. */
|
|
46
48
|
admitBudget?: (envs: Envelope[], unit: ExecutionUnit) => Promise<ExecutionBudgetDecision[]>;
|
|
@@ -113,6 +115,66 @@ export class PiPeer extends BasePeer {
|
|
|
113
115
|
private currentReply?: Envelope;
|
|
114
116
|
private sessionId = "";
|
|
115
117
|
private sessionFile = "";
|
|
118
|
+
/** A read-only observation of Pi's native slot, never a second writable title store. */
|
|
119
|
+
private observedTitle: string | undefined;
|
|
120
|
+
private titleRevision = -1;
|
|
121
|
+
private titleEpoch = 0;
|
|
122
|
+
private nativeTitleEpoch: string | undefined;
|
|
123
|
+
get sessionTitle(): string | undefined { return this.acceptingTools && this.verifiedOwner() ? this.observedTitle : undefined; }
|
|
124
|
+
get titleSession(): string | undefined { return this.nativeSession; }
|
|
125
|
+
async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
|
|
126
|
+
const invalid = (error: string, mayHaveApplied = false): SessionTitleResult => ({ ok: false, reason: "invalid", error, ...(mayHaveApplied ? { mayHaveApplied: true as const } : {}) });
|
|
127
|
+
if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean") return invalid("Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again.");
|
|
128
|
+
if (this.stopping || this.state === "offline") return invalid("Pi session is offline or stopped; start or reconnect Pi and check ahub status before naming it.");
|
|
129
|
+
const session = this.sessionId, generation = this.budgetGeneration, owner = this.sessionGeneration, epoch = this.titleEpoch;
|
|
130
|
+
if (!owner || !this.verifiedOwner()) return invalid("Pi native session owner is unverified; reconnect the retained Pi session and check ahub status before naming it.");
|
|
131
|
+
if (!this.acceptingTools) return invalid("Pi session is detached or running a personal turn; reconnect its current hub session and check ahub status before naming it.");
|
|
132
|
+
const valid = () => {
|
|
133
|
+
try { return this.acceptingTools && this.verifiedOwner() && this.sessionGeneration === owner && this.sessionId === session && this.budgetGeneration === generation && this.titleEpoch === epoch && current?.() !== false; }
|
|
134
|
+
catch { return false; }
|
|
135
|
+
};
|
|
136
|
+
if (!valid()) return invalid("Pi session, turn or title admission changed before dispatch; check ahub status before retrying in the current public session.");
|
|
137
|
+
let dispatched = false;
|
|
138
|
+
try {
|
|
139
|
+
const result = await this.sendTui({ type: "set_session_title", title, onlyIfUnnamed, sessionId: session, generation, ...(this.nativeTitleEpoch ? { titleEpoch: this.nativeTitleEpoch } : {}) }, valid, () => { dispatched = true; });
|
|
140
|
+
if (!valid()) return invalid("Pi session, turn or title admission changed after dispatch; the title may have been applied; check ahub status before retrying.", true);
|
|
141
|
+
if (result?.ok === false && result.reason === "invalid") {
|
|
142
|
+
if (result.cause === "session") return invalid("Pi native session changed before the title write; check ahub status and reconnect the current session before retrying.");
|
|
143
|
+
if (result.cause === "generation") return invalid("Pi native turn changed before the title write; wait for the current turn and check ahub status before retrying.");
|
|
144
|
+
if (result.cause === "arguments") return invalid("Pi rejected invalid title command arguments; use a 1-80 character normalized title and retry in the current session.");
|
|
145
|
+
if (result.cause === "native_unavailable") return invalid("Pi native title API is unavailable; restart Pi with the supported hub extension and check ahub status before retrying.");
|
|
146
|
+
if (result.cause === "native_failed") return invalid("Pi native title API failed without confirming the write; the title may have been applied; check ahub status before retrying.", true);
|
|
147
|
+
if (result.cause === "native_readback") return invalid("Pi native title readback did not confirm the write; the title may have been applied; check ahub status before retrying.", true);
|
|
148
|
+
}
|
|
149
|
+
if (result?.sessionId !== session || typeof result.sessionName !== "string" || this.nativeTitleEpoch !== undefined && result.titleEpoch !== this.nativeTitleEpoch) return invalid("Pi title acknowledgement did not confirm the current native slot; the title may have been applied; check ahub status before retrying.", true);
|
|
150
|
+
this.observeTitle(result.sessionName, result.titleRevision, result.titleEpoch);
|
|
151
|
+
if (result.ok === false && result.reason === "already_named") return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
|
|
152
|
+
if (result.ok === true && result.sessionName === title) return { ok: true };
|
|
153
|
+
return invalid("Pi native title write or readback was not confirmed; the title may have been applied; check ahub status before retrying.", true);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const timeout = error instanceof Error && error.message.includes("acknowledgement timed out");
|
|
156
|
+
if (dispatched) return invalid(`Pi title ${timeout ? "acknowledgement timed out" : "bridge command failed after dispatch"}; the title may have been applied; check ahub status before retrying.`, true);
|
|
157
|
+
return invalid(`Pi title ${timeout ? "command expired" : "admission changed or bridge failed"} before dispatch; check ahub status before retrying in the current session.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
private observeTitle(value: unknown, revision?: unknown, epoch?: unknown): void {
|
|
161
|
+
if (this.nativeTitleEpoch !== undefined && epoch !== this.nativeTitleEpoch) return;
|
|
162
|
+
if (typeof revision === "number" && Number.isSafeInteger(revision) && revision >= 0) {
|
|
163
|
+
if (revision < this.titleRevision) return;
|
|
164
|
+
this.titleRevision = revision;
|
|
165
|
+
}
|
|
166
|
+
if (value === undefined) value = ""; // A verified native session omits its name while unnamed.
|
|
167
|
+
if (typeof value !== "string") return;
|
|
168
|
+
if (value) this.noteActivity(); // Native session_info is history even before Pi creates its session file.
|
|
169
|
+
if (this.observedTitle !== value) { this.observedTitle = value; this.onDoing?.("title", value); }
|
|
170
|
+
}
|
|
171
|
+
private observeSnapshotTitle(snapshot: { sessionName?: unknown; titleRevision?: unknown; titleEpoch?: unknown }, before: number, beforeEpoch: number): boolean {
|
|
172
|
+
if (this.titleEpoch !== beforeEpoch || snapshot.titleEpoch !== undefined && snapshot.titleEpoch !== this.nativeTitleEpoch) return false;
|
|
173
|
+
if (snapshot.titleRevision === undefined && this.titleRevision !== before) return false;
|
|
174
|
+
if (typeof snapshot.titleRevision === "number" && snapshot.titleRevision < this.titleRevision) return false;
|
|
175
|
+
this.observeTitle(snapshot.sessionName, snapshot.titleRevision, snapshot.titleEpoch ?? this.nativeTitleEpoch);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
116
178
|
private executionBudgetTimer?: ReturnType<typeof setTimeout>;
|
|
117
179
|
private executionAbort?: AbortController;
|
|
118
180
|
private readonly budgetStops = new Map<number, string>();
|
|
@@ -164,7 +226,7 @@ export class PiPeer extends BasePeer {
|
|
|
164
226
|
private terminalOwnerObservation?: TerminalOwnerObservation;
|
|
165
227
|
private tuiExit?: Promise<void>;
|
|
166
228
|
private resolveTuiExit?: () => void;
|
|
167
|
-
private tuiCommands = new Map<string, { command: Record<string, unknown>; resolve: (value: any) => void; reject: (error: Error) => void }>();
|
|
229
|
+
private tuiCommands = new Map<string, { command: Record<string, unknown>; current?: () => boolean; onDispatch?: () => void; resolve: (value: any) => void; reject: (error: Error) => void }>();
|
|
168
230
|
private tuiQueue: Record<string, unknown>[] = [];
|
|
169
231
|
private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
|
|
170
232
|
|
|
@@ -219,14 +281,16 @@ export class PiPeer extends BasePeer {
|
|
|
219
281
|
this.emptyResumeVerified = false;
|
|
220
282
|
if (this.state !== "idle" || this.stopping || this.activeTools) throw new Error("Pi must settle before session capture");
|
|
221
283
|
if (this.sessionFile && existsSync(this.sessionFile)) return this.recoveryMetadata();
|
|
284
|
+
const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
|
|
222
285
|
const snapshot = this.opts.mode === "headless"
|
|
223
286
|
? (await this.waitRpc("get_state", 15_000)).data
|
|
224
287
|
: await this.sendTui({ type: "get_session_state" });
|
|
225
288
|
if (this.state !== "idle" || this.activeTools || snapshot?.sessionId !== this.sessionId || snapshot?.sessionFile !== this.sessionFile) throw new Error("Pi source session changed during capture");
|
|
289
|
+
if (!this.observeSnapshotTitle(snapshot, titleRevisionBefore, titleEpochBefore)) throw new Error("Pi source session title changed during capture; retry with a fresh native session snapshot");
|
|
226
290
|
const empty = this.opts.mode === "headless"
|
|
227
291
|
? snapshot.messageCount === 0 && snapshot.pendingMessageCount === 0 && snapshot.isStreaming === false && snapshot.isCompacting !== true && !snapshot.sessionName
|
|
228
292
|
: snapshot.empty === true && snapshot.idle === true;
|
|
229
|
-
if (!empty || this.activityObserved || this.persistedOnce) throw new Error("Pi session history is not persisted; refusing to recreate it");
|
|
293
|
+
if (!empty || this.activityObserved || this.persistedOnce) throw new Error("Pi session history is not persisted; refusing to recreate it. Exchange one message or start a new session, then retry");
|
|
230
294
|
this.emptyResumeVerified = true;
|
|
231
295
|
const captured = this.recoveryMetadata();
|
|
232
296
|
if (typeof captured.sessionId === "string" && !captured.sessionFile) this.verifiedEmptyResume = { sessionId: captured.sessionId };
|
|
@@ -388,6 +452,7 @@ export class PiPeer extends BasePeer {
|
|
|
388
452
|
if (this.preflight) await this.preflight();
|
|
389
453
|
this.terminalOwnerObservation = undefined;
|
|
390
454
|
this.stopping = false;
|
|
455
|
+
this.observedTitle = undefined; this.titleRevision = -1; this.nativeTitleEpoch = undefined; this.onDoing?.("reset");
|
|
391
456
|
this.rosterDetached = false; this.rosterUnownedRun = false;
|
|
392
457
|
this.requestedStopReason = undefined;
|
|
393
458
|
this.approvalTurnAbort = false;
|
|
@@ -456,6 +521,7 @@ export class PiPeer extends BasePeer {
|
|
|
456
521
|
this.fail(new Error(`pi exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
|
|
457
522
|
this.reportExit(exit);
|
|
458
523
|
});
|
|
524
|
+
const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
|
|
459
525
|
const state = await this.waitRpc("get_state", 15_000);
|
|
460
526
|
if (state.success !== true || typeof state.data?.sessionId !== "string" || typeof state.data?.sessionFile !== "string" || !state.data.sessionId || !state.data.sessionFile) throw new Error("Pi get_state did not prove session identity");
|
|
461
527
|
if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
|
|
@@ -472,6 +538,7 @@ export class PiPeer extends BasePeer {
|
|
|
472
538
|
}
|
|
473
539
|
this.persistedOnce = existsSync(this.sessionFile);
|
|
474
540
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) throw new Error("Pi startup session identity mismatch");
|
|
541
|
+
this.observeSnapshotTitle(state.data, titleRevisionBefore, titleEpochBefore);
|
|
475
542
|
this.started = true;
|
|
476
543
|
this.setState("idle");
|
|
477
544
|
}
|
|
@@ -542,9 +609,11 @@ export class PiPeer extends BasePeer {
|
|
|
542
609
|
if (!this.fencedPrompt) throw new Error("Pi roster return needs a fenced-prompt extension; restart the retained terminal with the current hub extension");
|
|
543
610
|
if (this.pendingPromptId) throw new Error("Pi prompt admission is still pending; wait before reverifying the retained terminal");
|
|
544
611
|
const epoch = this.rosterEpoch, lifecycle = this.rosterLifecycleRevision, session = this.sessionId, file = this.sessionFile;
|
|
612
|
+
const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
|
|
545
613
|
const snapshot = await this.sendTui({ type: "get_session_state" });
|
|
546
614
|
if (epoch !== this.rosterEpoch || this.stopping || !this.verifiedOwner() || this.activeTools || snapshot?.sessionId !== session || snapshot?.sessionFile !== file || typeof snapshot?.idle !== "boolean") throw new Error("Pi roster return could not verify the current native session");
|
|
547
615
|
if (lifecycle !== this.rosterLifecycleRevision) throw new Error("Pi native activity changed during roster return; retry with a fresh session snapshot");
|
|
616
|
+
this.observeSnapshotTitle(snapshot, titleRevisionBefore, titleEpochBefore);
|
|
548
617
|
this.rosterDetached = false;
|
|
549
618
|
this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
|
|
550
619
|
this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
|
|
@@ -650,8 +719,9 @@ export class PiPeer extends BasePeer {
|
|
|
650
719
|
this.noteActivity();
|
|
651
720
|
attempted = true;
|
|
652
721
|
if (deliveryId) this.activeDeliveryIds.add(deliveryId);
|
|
653
|
-
|
|
654
|
-
|
|
722
|
+
const message = [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n");
|
|
723
|
+
if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message }); promptId = this.pendingPromptId; await pending; }
|
|
724
|
+
else await this.sendRpc({ type: "prompt", message });
|
|
655
725
|
if (promptId !== undefined && promptId !== this.pendingPromptId) return;
|
|
656
726
|
if (this.fencedPrompt && this.rosterDetached) {
|
|
657
727
|
if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi native activity changed before prompt acceptance was confirmed" });
|
|
@@ -780,10 +850,15 @@ export class PiPeer extends BasePeer {
|
|
|
780
850
|
this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
|
|
781
851
|
this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
|
|
782
852
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
|
|
853
|
+
this.titleRevision = -1; this.titleEpoch++;
|
|
854
|
+
this.nativeTitleEpoch = typeof event.titleEpoch === "string" && event.titleEpoch ? event.titleEpoch : undefined;
|
|
855
|
+
this.observeTitle(event.sessionName, event.titleRevision, event.titleEpoch);
|
|
783
856
|
this.rememberTerminalOwner();
|
|
784
857
|
this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
|
|
785
858
|
}
|
|
859
|
+
if ((event.type === "session_title" || event.type === "agent_start" && Object.hasOwn(event, "sessionName")) && event.sessionId === this.sessionId && this.verifiedOwner()) this.observeTitle(event.sessionName, event.titleRevision, event.titleEpoch);
|
|
786
860
|
if (event.type === "session_shutdown") {
|
|
861
|
+
this.observedTitle = undefined; this.titleRevision = -1; this.nativeTitleEpoch = undefined; this.onDoing?.("reset");
|
|
787
862
|
const exit = this.exitMetadata("session_shutdown");
|
|
788
863
|
this.shutdownExit ??= exit; // headless waits for the OS exit, retaining the pre-cleanup turn and stop facts
|
|
789
864
|
this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
@@ -872,10 +947,17 @@ export class PiPeer extends BasePeer {
|
|
|
872
947
|
while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).replace(/\r$/, ""); this.buffer = this.buffer.slice(index + 1); if (!line) continue; try { this.handleRpc(JSON.parse(line) as RpcMessage); } catch (error) { this.opts.log?.(`[${this.id}] invalid Pi RPC output: ${(error as Error).message}`); } }
|
|
873
948
|
}
|
|
874
949
|
private nextTuiCommand(timeout: number): Promise<Record<string, unknown> | undefined> {
|
|
875
|
-
|
|
950
|
+
let next = this.tuiQueue.shift();
|
|
951
|
+
while (next) {
|
|
952
|
+
const pending = this.tuiCommands.get(String(next.id));
|
|
953
|
+
if (pending && pending.current?.() !== false) { pending.onDispatch?.(); return Promise.resolve(next); }
|
|
954
|
+
if (pending) { this.tuiCommands.delete(String(next.id)); pending.reject(new Error("Pi session title admission changed before dispatch")); }
|
|
955
|
+
next = this.tuiQueue.shift();
|
|
956
|
+
}
|
|
876
957
|
return new Promise((resolvePromise) => { const entry = { resolve: resolvePromise, timer: setTimeout(() => { const i = this.tuiWaiters.indexOf(entry); if (i >= 0) this.tuiWaiters.splice(i, 1); resolvePromise(undefined); }, timeout) }; this.tuiWaiters.push(entry); });
|
|
877
958
|
}
|
|
878
|
-
private sendTui(command: Record<string, unknown
|
|
959
|
+
private sendTui(command: Record<string, unknown>, current?: () => boolean, onDispatch?: () => void): Promise<any> {
|
|
960
|
+
if (current?.() === false) return Promise.reject(new Error("Pi session title admission changed before dispatch"));
|
|
879
961
|
const id = `ahub-${++this.seq}`;
|
|
880
962
|
const fenced = this.fencedPrompt && command.type === "prompt";
|
|
881
963
|
if (fenced) { this.pendingPromptId = id; this.hubPromptId = id; this.hubPromptGeneration = undefined; }
|
|
@@ -886,9 +968,9 @@ export class PiPeer extends BasePeer {
|
|
|
886
968
|
this.tuiQueue = this.tuiQueue.filter((queued) => queued.id !== id);
|
|
887
969
|
reject(new Error(`Pi TUI ${String(command.type)} acknowledgement timed out`));
|
|
888
970
|
}, 30_000);
|
|
889
|
-
this.tuiCommands.set(id, { command: wire, resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
|
|
971
|
+
this.tuiCommands.set(id, { command: wire, current, onDispatch, resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
|
|
890
972
|
const waiter = this.tuiWaiters.shift();
|
|
891
|
-
if (waiter) { clearTimeout(waiter.timer); waiter.resolve(wire); } else this.tuiQueue.push(wire);
|
|
973
|
+
if (waiter) { clearTimeout(waiter.timer); onDispatch?.(); waiter.resolve(wire); } else this.tuiQueue.push(wire);
|
|
892
974
|
});
|
|
893
975
|
}
|
|
894
976
|
private handleRpc(message: RpcMessage): void { if (this.acceptingTools && ["message_update", "tool_execution_update"].includes(message.type ?? "") && this.state === "busy") this.touch(); if (message.type === "agent_settled") return; // The authenticated extension is the single lifecycle source.
|
|
@@ -204,6 +204,10 @@ export interface AdapterDefinition {
|
|
|
204
204
|
conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
|
|
205
205
|
};
|
|
206
206
|
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin"; currentChild?: true };
|
|
207
|
+
/** Who owns the session title; only hub-owned slots receive hub_set_title. */
|
|
208
|
+
title?: "native" | "hub";
|
|
209
|
+
/** Fixed builtins only: the title slot survives native-session restoration. Absent means ephemeral. */
|
|
210
|
+
titlePersistence?: "native";
|
|
207
211
|
/** External module data-only opt-in. The verified loader derives the internal instances policy from it (#754). */
|
|
208
212
|
headlessInstances?: { form: "headless"; roles: readonly ("implementer" | "verifier" | "reviewer")[] };
|
|
209
213
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
package/src/adapters/registry.ts
CHANGED
|
@@ -239,6 +239,8 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
239
239
|
if (isFixedBuiltinDefinition(definition)) return;
|
|
240
240
|
const declared = (block: object | undefined, key: string): boolean => block !== undefined && key in block;
|
|
241
241
|
const restricted: [string, boolean][] = [
|
|
242
|
+
["title", definition.title !== undefined],
|
|
243
|
+
["titlePersistence", definition.titlePersistence !== undefined],
|
|
242
244
|
["start.acpCommand", declared(definition.start, "acpCommand")],
|
|
243
245
|
["attach.admission", declared(definition.attach, "admission")],
|
|
244
246
|
["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
|
package/src/cli/autonomy.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import type { AutonomyPeerStatus } from "../hub/autonomy.ts";
|
|
3
|
+
import type { ClaudeHomeCheck } from "./claude-homes.ts";
|
|
4
|
+
|
|
1
5
|
export { autonomyEnvironment } from "../hub/autonomy.ts";
|
|
2
6
|
export const AUTONOMY_USAGE = "usage: ahub autonomy on [--all-projects] [--yes] | off [--all-projects] | status [--json]";
|
|
3
7
|
export interface AutonomyCliPorts {
|
|
@@ -40,5 +44,66 @@ export async function runAutonomy(args: string[], ports: AutonomyCliPorts): Prom
|
|
|
40
44
|
}
|
|
41
45
|
if (action === "status" && ports.decorateStatus) reply = await ports.decorateStatus(reply);
|
|
42
46
|
if (action === "status" && args.includes("--json")) ports.print(JSON.stringify(reply.status));
|
|
43
|
-
else
|
|
47
|
+
else {
|
|
48
|
+
const lines = action === "status" && reply.status && typeof reply.status === "object"
|
|
49
|
+
? renderAutonomyStatus(reply.status as Record<string, unknown>) : null;
|
|
50
|
+
for (const line of lines ?? (Array.isArray(reply.lines) ? reply.lines : typeof reply.text === "string" ? [reply.text] : [])) if (typeof line === "string") ports.print(line);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Human presentation only; the daemon report and JSON wire output stay unchanged. */
|
|
55
|
+
export function renderAutonomyStatus(status: Record<string, unknown>, home = homedir()): string[] | null {
|
|
56
|
+
if (!Array.isArray(status.peers)) return null;
|
|
57
|
+
const peers = status.peers as AutonomyPeerStatus[];
|
|
58
|
+
const machine = status.machineDefault as { enabled: boolean; notice?: string } | undefined;
|
|
59
|
+
const lines = [`Project autonomy · environment ${status.environment}${machine ? ` · machine default ${machine.enabled ? "on" : "off"}` : ""}`];
|
|
60
|
+
if (status.runtime === "stopped") lines.push("The hub is stopped; effective runtime modes and retained consent are unknown.");
|
|
61
|
+
lines.push("");
|
|
62
|
+
if (peers.length) {
|
|
63
|
+
const rows = [["PEER", "NOW", "NEXT START"], ...peers.map(peer => [peer.id, peer.effectiveMode, peer.nextMode ?? "unmanaged"])];
|
|
64
|
+
const widths = rows[0]!.map((_, column) => Math.max(...rows.map(row => row[column]!.length)));
|
|
65
|
+
for (const row of rows) lines.push(row.map((value, column) => column < row.length - 1 ? value.padEnd(widths[column]!) : value).join(" "));
|
|
66
|
+
} else lines.push("No peers were inspected.");
|
|
67
|
+
|
|
68
|
+
const floors = new Map<string, { row: AutonomyPeerStatus["floor"][number]; peers: Set<string> }>();
|
|
69
|
+
const classes = new Set<string>(), enforced = new Set<string>();
|
|
70
|
+
const unknowns = new Map<string, Set<string>>();
|
|
71
|
+
for (const peer of peers) {
|
|
72
|
+
for (const row of peer.floor ?? []) {
|
|
73
|
+
classes.add(row.class);
|
|
74
|
+
if (row.status === "hub-enforced" || row.status === "structural-guard") enforced.add(row.class);
|
|
75
|
+
const key = JSON.stringify([row.class, row.status, row.reason]);
|
|
76
|
+
let group = floors.get(key);
|
|
77
|
+
if (!group) { group = { row, peers: new Set() }; floors.set(key, group); }
|
|
78
|
+
group.peers.add(peer.id);
|
|
79
|
+
}
|
|
80
|
+
for (const text of peer.missing ?? []) {
|
|
81
|
+
if ((peer.floor ?? []).some(row => text.startsWith(`${row.class}: `))) continue;
|
|
82
|
+
let group = unknowns.get(text);
|
|
83
|
+
if (!group) { group = new Set(); unknowns.set(text, group); }
|
|
84
|
+
group.add(peer.id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (peers.length) lines.push("", `Guarded actions (${classes.size} classes; ${enforced.size} enforced for at least one peer)`);
|
|
88
|
+
for (const { row, peers: members } of floors.values()) {
|
|
89
|
+
lines.push(` ${row.class} · ${row.status} · ${members.size === peers.length ? "all peers" : [...members].join(", ")}`, ` ${row.reason}`);
|
|
90
|
+
}
|
|
91
|
+
const projectMissing = Array.isArray(status.missing) ? status.missing.filter((text): text is string => typeof text === "string") : [];
|
|
92
|
+
if (unknowns.size || projectMissing.length) {
|
|
93
|
+
lines.push("", "Unknowns");
|
|
94
|
+
for (const text of projectMissing) lines.push(` project: ${text}`);
|
|
95
|
+
for (const [text, members] of unknowns) lines.push(` ${[...members].join(", ")}: ${text}`);
|
|
96
|
+
}
|
|
97
|
+
if (machine?.notice) lines.push("", machine.notice);
|
|
98
|
+
const notices = status.claudeHomeNotices as string[] | undefined;
|
|
99
|
+
if (Array.isArray(status.claudeHomes) || notices?.length) {
|
|
100
|
+
lines.push("", "Claude homes");
|
|
101
|
+
for (const notice of notices ?? []) lines.push(` ${notice}`);
|
|
102
|
+
for (const profile of (status.claudeHomes ?? []) as ClaudeHomeCheck[]) {
|
|
103
|
+
const path = profile.home.path;
|
|
104
|
+
const display = path === home ? "~" : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
105
|
+
lines.push(` ${display}: plugin ${profile.plugin.state}; hook ${profile.hook.state}. ${profile.hook.reason}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return lines;
|
|
44
109
|
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -399,7 +399,7 @@ const KEYS: [string, string[]][] = [
|
|
|
399
399
|
["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
|
|
400
400
|
["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Home/< first, End/> last", "Enter view", "j/k scroll a view", "w web UI (outside Peers)", "o pet window"]],
|
|
401
401
|
["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
|
|
402
|
-
["Peers", ["p pause", "r resume", "m permission mode", "w WIP limit", "l make planner (then y)"]],
|
|
402
|
+
["Peers", ["p pause", "r resume", "m permission mode", "w WIP limit", "l make planner (then y)", "t rename Pi/local session"]],
|
|
403
403
|
["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r approve or request changes (in review; a or c, then a note)", "p pull, e plan (backlog)", "R return to backlog", "c cancel (then y)"]],
|
|
404
404
|
["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry", "D discard the peer's whole queued backlog (reason, then y)"]],
|
|
405
405
|
["Events", ["f peer filter", "g kind filter"]],
|
|
@@ -857,6 +857,10 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
857
857
|
if (choice) s.reviewChoice = choice;
|
|
858
858
|
return done();
|
|
859
859
|
}
|
|
860
|
+
if (s.panel === 1 && key === "t") {
|
|
861
|
+
if (definitionOf(String(item))?.title !== "hub") { notify(s, "Rename this session in its native UI", now); return done(); }
|
|
862
|
+
s.input = `title ${item} `; s.editing = true; return done();
|
|
863
|
+
}
|
|
860
864
|
const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` : undefined;
|
|
861
865
|
if (prefill) { s.input = prefill; s.editing = true; return done(); }
|
|
862
866
|
if (s.panel === 3 && key === "c") {
|
|
@@ -945,7 +949,7 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
945
949
|
span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
|
|
946
950
|
span(b?.windows?.length ? b.windows.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${relative(w.resetsAt, now)}` : ""}`).join(", ") : "-"),
|
|
947
951
|
span(row.servedBy ?? row.requestedModel ?? "-"),
|
|
948
|
-
span(typeof row.doing?.text === "string" && row.doing.text ? row.doing.text : "-", row.doing?.text === DOING_STUB ? "muted" : undefined)]; // #878: screened by the hub
|
|
952
|
+
span(typeof row.doing?.text === "string" && row.doing.text ? row.doing.text : "-", row.doing?.text === DOING_STUB || row.doing?.source === "label" ? "muted" : undefined)]; // #878: screened by the hub
|
|
949
953
|
}
|
|
950
954
|
if (s.panel === 7) {
|
|
951
955
|
const text = usageCells(s.usage, row.id, s.budget[row.id], at => relative(at, now));
|
package/src/cli/help.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface HelpEntry { section: string; usage: string; description: string
|
|
|
8
8
|
const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
9
9
|
["Projects", [
|
|
10
10
|
["ahub specs list [--json] | check [--require-fresh] [--base <ref>] [--json] | covers <file...> | stamp <id-or-document...> [--yes]", "read design specs, validate their graph and source freshness, or find covering specs; runs without a daemon; check exits 1 on errors and, with --require-fresh, stale documents; stamp is person only (a terminal, never an agent): it shows old -> new, asks (--yes skips the question but still needs a terminal), and then changes only the verified-against line (or the stamps-file entry) to the full HEAD SHA, refused while the covered paths have uncommitted changes"],
|
|
11
|
+
["ahub worktrees [--json] | tidy [--json] | tidy --confirm <fingerprint> [--json]", "list live and settled worktrees; preview what is safe to remove; `--confirm` (the person only) runs git worktree remove and git branch -d, never forced"],
|
|
11
12
|
["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
|
|
12
13
|
["ahub projects [--json] [--full] [--color=auto|always|never]", "list registered projects and live status"],
|
|
13
14
|
["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
|
|
@@ -80,6 +81,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
80
81
|
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "validated settings: conductor set is limited to exact live operational keys; undo/reload and other keys are person only; --preview saves nothing; conductor writes keep the person undo record"],
|
|
81
82
|
["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, research.runners, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
|
|
82
83
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
84
|
+
["ahub title <peer> <text...>", "person only: replace a Pi or local-worker session title; native-title peers rename in their own UI"],
|
|
83
85
|
["ahub queue list [--peer <id>] [--all] [--json] [--full] [--color=auto|always|never]", "inspect durable deliveries; open to any agent as public queue metadata, held bodies stay with the person or conductor; rows a resolution already settled (a retried parent, a discarded or completed row) are hidden and counted on stderr, --all shows them"],
|
|
84
86
|
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "person or configured conductor: inspect one delivery and revision; agent output uses the public view"],
|
|
85
87
|
["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N] [--json] [--color=auto|always|never]", "person or configured conductor: resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show; a queued delivery can be discarded while its peer is busy, and a refusal names its cause"],
|
|
@@ -88,7 +90,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
88
90
|
["Tasks and review", [
|
|
89
91
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person or configured conductor: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
90
92
|
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--handoffs] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for; --handoffs: what this project handed to other projects and the last state each reported"],
|
|
91
|
-
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--needs-network] [--needs-worktree] [--backlog] [--detail <text>]", "put a task on the board; --needs-network keeps it away from a peer whose shell has none; --needs-worktree keeps it away from a sandboxed peer
|
|
93
|
+
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--needs-network] [--needs-worktree] [--backlog] [--detail <text>]", "put a task on the board; --needs-network keeps it away from a peer whose shell has none; --needs-worktree keeps it away from a sandboxed peer unless the hub can make the task worktree for it (it makes it, commits and pushes at done); 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"],
|
|
92
94
|
["ahub task handoff --project <root|id> [<class>] <title...> [--path <p>]... [--issue <n>] [--task <n>] [--detail <text>] [--json] [--color=auto|always|never]", "person only: register work that belongs to ANOTHER project that runs agent-hub on that project's board (an ownerless proposal its own roles take from there); the target must be registered and running, nothing is queued; a feature or behavior change first needs a spec issue there (--issue); its state changes show in ahub board --handoffs (an agent that hands off gets them as task messages)"],
|
|
93
95
|
["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision> [--json] [--color=auto|always|never]", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
|
|
94
96
|
["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision> [--json] [--color=auto|always|never]", "person only, except show (any agent, public metadata): planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -28,9 +28,11 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
|
|
|
28
28
|
|| (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept" || args[0] === "accept-departure"))
|
|
29
29
|
|| (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet"
|
|
30
30
|
// #691: trusting an untested agent CLI version is the person's consent, never a delegated live-test operation.
|
|
31
|
-
|| command === "autonomy"
|
|
31
|
+
|| command === "autonomy" || command === "title"
|
|
32
32
|
// #1064 T3: a stamp is a person's record that they reviewed a document; no delegation, live-test or otherwise, may write one.
|
|
33
33
|
|| (command === "specs" && args[0] === "stamp")
|
|
34
|
+
// #1057: removing a worktree and its branch is the person's own act, in their own terminal; no delegation, live-test or otherwise, may confirm a tidy.
|
|
35
|
+
|| (command === "worktrees" && args.some(arg => arg === "--confirm" || arg.startsWith("--confirm=")))
|
|
34
36
|
|| (command === "agents" && !args.every(arg => arg === "--json"));
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -79,6 +81,8 @@ interface PeerReadForm {
|
|
|
79
81
|
const publicReadDisplayFlags = ["--json", "--full", "--color=auto", "--color=always", "--color=never"] as const;
|
|
80
82
|
const sdlcReadDisplayFlags = ["--json", "--color=auto", "--color=always", "--color=never"] as const;
|
|
81
83
|
const peerReadForms: Readonly<Record<string, Readonly<Record<string, PeerReadForm>>>> = {
|
|
84
|
+
// #1057: listing the shelf and previewing a tidy are reads an agent may run; `tidy --confirm` and any other form fall to `console` below, and live-test refuses them.
|
|
85
|
+
worktrees: { "": { displayFlags: ["--json", "--full", "--color=auto", "--color=always", "--color=never"] }, tidy: { displayFlags: ["--json", "--color=auto", "--color=always", "--color=never"] } },
|
|
82
86
|
specs: { list: { displayFlags: ["--json"] }, check: { valueFlags: ["--base"], displayFlags: ["--json", "--require-fresh"] }, covers: { positional: 2000, minimum: 1, displayFlags: [] } },
|
|
83
87
|
room: { list: { displayFlags: ["--json"] }, status: { positional: 1, displayFlags: ["--json"] } },
|
|
84
88
|
queue: { list: { valueFlags: ["--peer"], displayFlags: [...publicReadDisplayFlags, "--all"] } },
|
|
@@ -205,7 +209,10 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
|
|
|
205
209
|
if (command === "autonomy") { const sub = args.find(arg => !arg.startsWith("--")); return sub !== undefined && ["on", "off", "status"].includes(sub) ? `autonomy ${sub}` : "autonomy"; }
|
|
206
210
|
if (command === "usage") return args[0] === "limits" || args[0] === "total" ? `usage ${args[0]}` : "usage";
|
|
207
211
|
if (command === "deliverables") return "deliverables";
|
|
212
|
+
if (command === "title") return "title";
|
|
208
213
|
if (command === "specs") return ["list", "check", "covers", "stamp"].includes(args[0] ?? "") ? `specs ${args[0]}` : "specs";
|
|
214
|
+
// #1057: the audit names the subcommand and whether it confirms, never a path, a branch or a fingerprint.
|
|
215
|
+
if (command === "worktrees") return args[0] === "tidy" ? (args.some(arg => arg === "--confirm" || arg.startsWith("--confirm=")) ? "worktrees tidy confirm" : "worktrees tidy") : "worktrees";
|
|
209
216
|
if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn", "study"].includes(command)) return "unknown";
|
|
210
217
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
|
|
211
218
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|