@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -3
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +4 -1
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -3
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/design/adapters.md +56 -0
  11. package/docs/design/agent-hub-architecture.md +12 -0
  12. package/docs/design/bus.md +8 -0
  13. package/docs/design/task-board.md +19 -1
  14. package/docs/dogfood.md +13 -7
  15. package/docs/operations.md +177 -20
  16. package/docs/quickstart.md +18 -3
  17. package/docs/sdlc.md +2 -2
  18. package/docs/security.md +45 -4
  19. package/docs/smoke.md +13 -2
  20. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  21. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  22. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  23. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  24. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  25. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  26. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  27. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  28. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  29. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  30. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  31. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  32. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  33. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  34. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  35. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  36. package/docs/verification/pi-150-model-free.ts +57 -0
  37. package/docs/verification/pi-150-native.mjs +21 -0
  38. package/docs/verification/pi-150-title-api.mjs +31 -0
  39. package/docs/verified.json +14 -14
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +715 -45
  43. package/src/adapters/compat.ts +10 -10
  44. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  45. package/src/adapters/local-worker.ts +22 -6
  46. package/src/adapters/pi.ts +92 -10
  47. package/src/adapters/registry-types.ts +4 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/cli/autonomy.ts +66 -1
  50. package/src/cli/console-state.ts +6 -2
  51. package/src/cli/help.ts +3 -1
  52. package/src/cli/identity.ts +8 -1
  53. package/src/cli/main.ts +39 -0
  54. package/src/cli/output.ts +5 -0
  55. package/src/cli/recovery-package.ts +9 -0
  56. package/src/cli/worktree-tidy.ts +158 -0
  57. package/src/cli/worktrees-output.ts +43 -0
  58. package/src/hub/board.ts +31 -5
  59. package/src/hub/bus.ts +4 -4
  60. package/src/hub/conductor.ts +4 -3
  61. package/src/hub/config-apply.ts +2 -1
  62. package/src/hub/control-client.ts +16 -1
  63. package/src/hub/control-write-trace.ts +409 -0
  64. package/src/hub/daemon.ts +408 -66
  65. package/src/hub/dogfood-read.ts +62 -9
  66. package/src/hub/hub-tools.ts +12 -6
  67. package/src/hub/manager.ts +9 -1
  68. package/src/hub/peer-doing.ts +33 -3
  69. package/src/hub/peers.ts +7 -0
  70. package/src/hub/routing.ts +9 -2
  71. package/src/hub/runtime-capture-prune.ts +4 -1
  72. package/src/hub/sdlc-release-authorization.ts +13 -4
  73. package/src/hub/sdlc-release.ts +23 -10
  74. package/src/hub/sdlc.ts +15 -2
  75. package/src/hub/settings.ts +3 -0
  76. package/src/hub/task-worktree.ts +331 -0
  77. package/src/hub/tasks.ts +192 -25
  78. package/src/hub/ui.ts +2 -0
  79. package/src/hub/worktree-prs.ts +100 -0
  80. package/src/hub/worktree-shelf.ts +246 -0
  81. package/src/local/deny.ts +49 -0
  82. package/src/local/tools.ts +4 -2
  83. package/src/memory/brief.ts +5 -0
  84. package/src/memory/capture.ts +1 -1
  85. package/src/memory/client.ts +5 -2
  86. package/src/pi/extension.ts +43 -3
  87. package/src/ui/index.html +133 -10
  88. package/src/ui/office-model.ts +64 -8
  89. package/src/ui/office-space.ts +1 -0
  90. package/src/ui/plugin.ts +14 -3
@@ -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
- // Receipt: docs/verification/2026-10-26-pi-140-compat.md
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-10-26-pi-140-compat.md" },
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.7 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
- receipt: "docs/verification/2026-10-26-agy-1.4.7.md" },
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-10-31-qwen-0264-acp.md" },
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);
@@ -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
- if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message: renderDigest(envs, true) }); promptId = this.pendingPromptId; await pending; }
654
- else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
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
- const next = this.tuiQueue.shift(); if (next) return Promise.resolve(next);
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>): Promise<any> {
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. */
@@ -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")],
@@ -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 for (const line of Array.isArray(reply.lines) ? reply.lines : typeof reply.text === "string" ? [reply.text] : []) if (typeof line === "string") ports.print(line);
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
  }
@@ -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, which cannot create the task worktree; 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"],
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"],
@@ -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];