@staix/agent-hub 0.19.0 → 0.21.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,142 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ControlClient, readControl } from "../hub/control-client.ts";
4
+ import { loadConfig, type HubConfig } from "../hub/daemon.ts";
5
+ import { captureRecoveryPolicy, readRecoveryPolicyInputs, type RecoveryPolicyInputs } from "../hub/recovery-policy.ts";
6
+ import { authorizeConductorRecovery, assertConductorRecovery, retireConductorRecovery, RecoveryAuthorityError, type ConductorRecoveryBinding } from "../hub/recovery-authority.ts";
7
+ import { operationPath } from "../hub/recovery-store.ts";
8
+ import { readRestartSnapshot } from "../hub/restart.ts";
9
+ import { processLiveness } from "../pi/process-signature.ts";
10
+ import { packageDigest, recoveryTargetDigest } from "./recovery-package.ts";
11
+ import type { Project } from "../hub/registry.ts";
12
+ import { planFingerprint, registeredProjects, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
13
+ import { realPath } from "../hub/project.ts";
14
+
15
+ function unavailable(): never { throw new RecoveryAuthorityError(); }
16
+ function validateConductorOperation(op: RecoveryOperation): void {
17
+ try {
18
+ const { fingerprint, ...body } = op.plan;
19
+ if (op.schema !== 1 || !["restart", "upgrade"].includes(op.plan.kind) || planFingerprint(body) !== fingerprint || !op.plan.projects.length
20
+ || op.plan.projects.length > 128 || new Set(op.plan.projects.map(row => row.project.id)).size !== op.plan.projects.length) unavailable();
21
+ } catch { unavailable(); }
22
+ }
23
+ export function conductorRecoveryBinding(op: RecoveryOperation, project: Pick<Project, "root" | "id" | "stateDir">, peer: string,
24
+ captured?: { config: HubConfig; inputs: RecoveryPolicyInputs }): ConductorRecoveryBinding {
25
+ validateConductorOperation(op);
26
+ const planned = op.plan.projects.find(row => row.project.id === project.id);
27
+ if (!planned?.source.instanceId || op.plan.projects.some(row => row.terminals.length > 0) || !op.targetRoot || !op.targetDigest || !op.targetFeatures?.includes("conductor_recovery") || op.disposition || op.audit?.length || op.freshOverride) unavailable();
28
+ const inputs = captured?.inputs ?? readRecoveryPolicyInputs(project.root, op.conductorRoutingDefaults?.[project.id]);
29
+ const candidate = captured?.config ?? loadConfig(project.root, inputs.layers, inputs.routing.retired ?? [], []);
30
+ const policy = captureRecoveryPolicy(project, peer, candidate, inputs);
31
+ return { schema: 1, operationId: op.id, peer, kind: op.plan.kind, projectId: project.id, projectRoot: project.root, stateDir: project.stateDir,
32
+ sourceInstanceId: planned.source.instanceId, targetRoot: op.targetRoot, targetDigest: op.targetDigest, planFingerprint: op.plan.fingerprint, ...policy };
33
+ }
34
+ export async function conductorRecoveryRequest(project: Project, peer: string, message: Record<string, unknown>): Promise<any> {
35
+ let client: ControlClient | undefined;
36
+ try {
37
+ const descriptor = readControl(project.stateDir);
38
+ if (!descriptor?.instanceId || !descriptor.features?.includes("conductor_recovery")) unavailable();
39
+ client = await ControlClient.connect(project.stateDir, { role: "tools", peer, projectId: project.id, projectRoot: project.root, instanceId: descriptor.instanceId });
40
+ if (!client.features.includes("conductor_recovery")) unavailable();
41
+ const result = await client.request(message, 30_000);
42
+ if (result.ok !== true) unavailable();
43
+ return result;
44
+ } catch { unavailable(); }
45
+ finally { client?.close(); }
46
+ }
47
+ /** No hold, lock, staging or package effect precedes proof in every affected project. */
48
+ export async function preflightConductorRecovery(plan: Pick<UpgradePlan, "projects" | "kind">, peer: string): Promise<Record<string, string>> {
49
+ const defaults: Record<string, string> = {};
50
+ if (!Array.isArray(plan.projects) || !plan.projects.length || plan.projects.some(row => !Array.isArray(row.terminals) || row.terminals.length > 0)) unavailable();
51
+ for (const row of plan.projects) {
52
+ const result = await conductorRecoveryRequest(row.project, peer, { t: "recovery_authority", op: "authorize", command: plan.kind });
53
+ if (row.source.instanceId && result.instanceId !== row.source.instanceId) unavailable();
54
+ if (result.routingDefault !== undefined) {
55
+ if (typeof result.routingDefault !== "string" || Buffer.byteLength(result.routingDefault) > 1024 * 1024) unavailable();
56
+ defaults[row.project.id] = result.routingDefault;
57
+ }
58
+ }
59
+ return defaults;
60
+ }
61
+ /** This is called only before the new public receipt exists; resume can never mint a grant. */
62
+ export async function issueConductorOperation(op: RecoveryOperation, peer: string): Promise<void> {
63
+ if (existsSync(operationPath(op.id))) unavailable();
64
+ await preflightConductorRecovery(op.plan, peer);
65
+ for (const row of op.plan.projects) {
66
+ const binding = conductorRecoveryBinding(op, row.project, peer);
67
+ await conductorRecoveryRequest(row.project, peer, { t: "recovery_authority", op: "issue", binding, plan: op.plan });
68
+ }
69
+ // A positive transport reply is not a source-issued grant. Prove every durable record before the public lock.
70
+ await authorizeConductorOperation(op, peer);
71
+ }
72
+ function localAuthority(op: RecoveryOperation, project: Project, peer: string) {
73
+ try {
74
+ const expected = conductorRecoveryBinding(op, project, peer);
75
+ if (packageDigest(op.sourceRoot, { ownDependencies: true }) !== op.plan.sourceDigest || recoveryTargetDigest(op.plan.kind, expected.targetRoot) !== expected.targetDigest) unavailable();
76
+ return authorizeConductorRecovery(project.stateDir, expected, () => {
77
+ try { return JSON.stringify(conductorRecoveryBinding(op, project, peer)) === JSON.stringify(expected); } catch { return false; }
78
+ });
79
+ } catch { unavailable(); }
80
+ }
81
+ /** Synchronous fence for the already authenticated private bootstrap's local effect boundaries. */
82
+ export function assertConductorOperationLocal(op: RecoveryOperation, peer: string): void {
83
+ validateConductorOperation(op);
84
+ for (const row of op.plan.projects) assertConductorRecovery(localAuthority(op, row.project, peer));
85
+ }
86
+ export async function authorizeConductorOperation(op: RecoveryOperation, peer: string): Promise<void> {
87
+ validateConductorOperation(op);
88
+ const proofs = [];
89
+ for (const row of op.plan.projects) {
90
+ const proof = localAuthority(op, row.project, peer);
91
+ proofs.push(proof);
92
+ const descriptor = readControl(row.project.stateDir);
93
+ if (descriptor) {
94
+ if (!descriptor.pid || !descriptor.pidSignature) unavailable();
95
+ const live = processLiveness(descriptor.pid, descriptor.pidSignature);
96
+ if (live !== "gone") {
97
+ if (live !== "live") unavailable();
98
+ await conductorRecoveryRequest(row.project, peer, { t: "recovery_authority", op: "verify", binding: proof.binding });
99
+ } else requireConductorCheckpoint(op, row.project);
100
+ } else requireConductorCheckpoint(op, row.project);
101
+ assertConductorRecovery(proof);
102
+ }
103
+ // A can change while B answers: every project is checked again after the last await.
104
+ for (const proof of proofs) assertConductorRecovery(proof);
105
+ }
106
+ export async function requireOwnedConductorTarget(op: RecoveryOperation, project: Project, peer: string): Promise<void> {
107
+ const binding = conductorRecoveryBinding(op, project, peer);
108
+ const result = await conductorRecoveryRequest(project, peer, { t: "recovery_authority", op: "verify", binding });
109
+ if (result.target !== true || result.operationId !== op.id || !["restored", "released"].includes(result.phase)) unavailable();
110
+ }
111
+ export function requireConductorCheckpoint(op: RecoveryOperation, project: Project): void {
112
+ try {
113
+ const snapshot = readRestartSnapshot(project.stateDir, { projectRoot: project.root, projectId: project.id, operationId: op.id });
114
+ if (!snapshot || snapshot.operationId !== op.id || snapshot.sourceInstanceId !== op.plan.projects.find(row => row.project.id === project.id)?.source.instanceId) unavailable();
115
+ } catch { unavailable(); }
116
+ }
117
+ export function retireConductorOperation(op: RecoveryOperation, peer: string): void {
118
+ if (op.phase !== "completed" && op.phase !== "cancelled") unavailable();
119
+ for (const row of op.plan.projects) retireConductorRecovery(localAuthority(op, row.project, peer));
120
+ }
121
+ /** Private launcher continuation, only the reviewed target and a captured effect, never arbitrary console argv. */
122
+ export async function authorizeConductorLaunch(op: RecoveryOperation, peer: string, root: string, targetRoot: string, command: string, args: readonly string[]): Promise<void> {
123
+ if (!["pending", "running", "blocked"].includes(op.phase)) unavailable();
124
+ await authorizeConductorOperation(op, peer);
125
+ if (!op.targetRoot || realPath(targetRoot) !== realPath(op.targetRoot)) unavailable();
126
+ const planned = op.plan.projects.find(row => row.project.root === root);
127
+ if (!planned) unavailable();
128
+ if ((command === "up" || command === "daemon") && args.length === 0) {
129
+ const descriptor = readControl(planned.project.stateDir);
130
+ if (descriptor && processLiveness(descriptor.pid, descriptor.pidSignature) !== "gone") unavailable();
131
+ const claim = registeredProjects().find(row => row.id === planned.project.id);
132
+ if (!claim || claim.root !== root || claim.stateDir !== planned.project.stateDir
133
+ || claim.pid !== null && processLiveness(claim.pid, claim.pidSignature) !== "gone"
134
+ || claim.pid === null && claim.instanceId !== null) unavailable();
135
+ requireConductorCheckpoint(op, planned.project); return;
136
+ }
137
+ if (command === "setup" && op.plan.kind === "upgrade" && args.length === 1 && args[0] === "--yes") {
138
+ for (const row of op.plan.projects) await requireOwnedConductorTarget(op, row.project, peer);
139
+ await authorizeConductorOperation(op, peer); return;
140
+ }
141
+ unavailable();
142
+ }
@@ -1,12 +1,14 @@
1
1
  import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
2
+ import { externalInstanceCatalog, type ExternalInstanceCatalog } from "../adapters/external-metadata.ts";
3
+ import type { DeclaredExternalInstance } from "../hub/instance-config.ts";
2
4
  import type { InstancesConfig } from "../hub/instances.ts";
3
5
  import { requireActiveInstance, requirePeerInstances } from "./launch.ts";
4
6
  import { configuredAcpPreview } from "./preview.ts";
5
7
 
6
8
  export interface ConfiguredAcpSelection { peer: string; mode: "headless"; sessionId?: string; preview: boolean }
7
- export function parseConfiguredAcpArguments(kind: string, raw: readonly string[], catalog: ProjectAdapterCatalog, instances?: InstancesConfig): ConfiguredAcpSelection {
9
+ export function parseConfiguredAcpArguments(kind: string, raw: readonly string[], catalog: ProjectAdapterCatalog, instances?: InstancesConfig, declaredExternalInstances: readonly DeclaredExternalInstance[] = []): ConfiguredAcpSelection {
8
10
  const definition = catalog.definitionOf(kind);
9
- if (!definition?.permission.genericAcp) throw new Error("unknown configured ACP kind");
11
+ if (!definition?.permission.genericAcp && !declaredExternalInstances.some(row => row.kind === kind)) throw new Error("unknown configured ACP kind");
10
12
  const values = new Map<string, string>();
11
13
  let preview = false;
12
14
  for (let index = 0; index < raw.length; index++) {
@@ -20,21 +22,35 @@ export function parseConfiguredAcpArguments(kind: string, raw: readonly string[]
20
22
  }
21
23
  if (values.has("--mode") && values.get("--mode") !== "headless") throw new Error("configured ACP supports headless only");
22
24
  const peer = values.get("--as") ?? kind;
23
- if (values.has("--as") && (!instances || !Object.hasOwn(instances, peer) || instances[peer]?.kind !== kind || !catalog.definitionOf(peer, instances))) throw new Error("--as must name a declared instance of this configured kind");
25
+ const executable = !!instances && Object.hasOwn(instances, peer) && instances[peer]?.kind === kind && !!catalog.definitionOf(peer, instances);
26
+ const declaredOnly = declaredExternalInstances.some(row => row.id === peer && row.kind === kind && row.availability === "declared-unknown" && row.refusalCode === "catalog-unavailable");
27
+ // Inert declarations select a preview or request id only; live daemon admission remains mandatory.
28
+ if (values.has("--as") && !executable && !declaredOnly) throw new Error("--as must name a declared instance of this configured kind");
24
29
  return { peer, mode: "headless", ...(values.has("--session-id") ? { sessionId: values.get("--session-id")! } : {}), preview };
25
30
  }
26
31
  export interface ConfiguredAcpControl {
27
32
  features: readonly string[];
33
+ readonly instanceId?: string;
34
+ readonly isOpen?: boolean;
35
+ readExternalInstanceCatalog?(projectRoot: string): Promise<ExternalInstanceCatalog>;
28
36
  request(message: Record<string, unknown>, timeout?: number): Promise<any>;
29
37
  close(): void;
30
38
  }
31
- export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind: string, peer: string, warn?: (text: string) => void): Promise<void> {
39
+ export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind: string, peer: string, warn?: (text: string) => void, external?: { projectRoot: string }): Promise<void> {
32
40
  if (!hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
33
41
  if (peer !== kind) requirePeerInstances(hub.features);
42
+ const epoch = hub.instanceId;
43
+ if (external && (!epoch || hub.isOpen !== true || !hub.readExternalInstanceCatalog || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => hub.features.includes(feature)))) throw new Error("running hub lacks a live external instance catalog");
34
44
  const reply = await hub.request({ t: "status" });
35
45
  const active = reply.status?.configuredAdapters;
36
46
  if (!Array.isArray(active) || !active.includes(kind)) throw new Error(`running hub has not loaded ${kind}; restart it after declaring the adapter`);
37
47
  if (peer !== kind) requireActiveInstance(reply.status, peer);
48
+ if (external) {
49
+ const observed = externalInstanceCatalog(await hub.readExternalInstanceCatalog!(external.projectRoot));
50
+ const row = observed.instances.find(row => row.id === peer);
51
+ if (hub.isOpen !== true || hub.instanceId !== epoch || reply.status?.instanceId !== epoch || reply.status?.cwd !== external.projectRoot
52
+ || observed.epoch !== epoch || observed.projectRoot !== external.projectRoot || !row || row.kind !== kind || !["ready", "offline"].includes(row.availability)) throw new Error("external instance is not bound to the current live catalog");
53
+ }
38
54
  const namespace = `adapters.${kind}`;
39
55
  const retained = [reply.status?.retainedConfig, reply.status?.pendingRestart].some(rows => Array.isArray(rows) && rows.some(row => {
40
56
  const key = row && typeof row === "object" ? row.key : undefined;
@@ -44,13 +60,17 @@ export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind
44
60
  }
45
61
  export async function runConfiguredAcpEntry(context: {
46
62
  kind: string; raw: readonly string[]; catalog: ProjectAdapterCatalog; instances?: InstancesConfig;
63
+ declaredExternalInstances?: readonly DeclaredExternalInstance[]; projectRoot?: string;
47
64
  connect(): Promise<ConfiguredAcpControl>; print(text: string): void; operationId?: string;
48
65
  }): Promise<void> {
49
- const selected = parseConfiguredAcpArguments(context.kind, context.raw, context.catalog, context.instances);
66
+ const selected = parseConfiguredAcpArguments(context.kind, context.raw, context.catalog, context.instances, context.declaredExternalInstances);
50
67
  if (selected.preview) { context.print(JSON.stringify(configuredAcpPreview(context.kind, selected.peer, selected.sessionId !== undefined), null, 2)); return; }
51
68
  const hub = await context.connect();
52
69
  try {
53
- await requireActiveConfiguredAcp(hub, context.kind, selected.peer, context.print);
70
+ const external = selected.peer !== context.kind && (context.declaredExternalInstances?.some(row => row.id === selected.peer && row.kind === context.kind)
71
+ || context.catalog.externalDefinitions.some(definition => definition.kind === context.kind));
72
+ if (external && !context.projectRoot) throw new Error("external instance start requires the canonical project root");
73
+ await requireActiveConfiguredAcp(hub, context.kind, selected.peer, context.print, external ? { projectRoot: context.projectRoot! } : undefined);
54
74
  const reply = await hub.request({ t: "start", peer: selected.peer, args: { mode: "headless", ...(selected.sessionId ? { sessionId: selected.sessionId } : {}) }, ...(context.operationId ? { operationId: context.operationId } : {}) }, 70_000);
55
75
  if (!reply.ok) throw new Error(typeof reply.error === "string" ? reply.error : "configured ACP start refused");
56
76
  context.print(reply.already ? `${selected.peer} is already attached` : `${selected.peer} attached (headless); use ahub say @${selected.peer} and the console for approvals`);
@@ -6,12 +6,15 @@ import { PLANNER_PEERS, definitionOf, nativeStreamTone, createProjectAdapterCata
6
6
  import { taskProgress } from "../ui/task-progress.ts";
7
7
  import type { ProgressStage } from "../ui/task-progress.ts";
8
8
  import { sanitize } from "../hub/envelope.ts";
9
+ import { DOING_STUB } from "../hub/peer-doing.ts";
9
10
  import type { SettingRow, SettingValue } from "../hub/settings.ts";
10
11
  import { symbolFields, overlapFields } from "./console-symbols.ts";
11
12
  import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
12
13
  import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
13
14
  import { windowKind } from "../hub/window-kind.ts";
15
+ import { ownerIsolationText, taskHolderText } from "./role-holders.ts";
14
16
  import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
17
+ import type { RoomStatusView } from "../rooms/status.ts";
15
18
 
16
19
  export interface Approval {
17
20
  id: string; peer: string; title: string; expiresAt: number;
@@ -55,6 +58,8 @@ export interface ConsoleState {
55
58
  taskSelection?: number; queueSelection?: string;
56
59
  /** #683 U3: the Usage panel (7): what the `usage` op last answered, and the task that was selected in Tasks when the panel was opened. */
57
60
  usage?: UsageState; usageTask?: number;
61
+ /** Absent on older hubs, null when the current hub cannot report room status. */
62
+ rooms?: RoomStatusView[] | null; roomSelection?: string;
58
63
  actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean; syntax?: "planning" | "ownerless-return"; revisionField?: "planningRevision" | "flowRevision" };
59
64
  confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string; lock?: ConsoleState["actionLock"] } | { type: "permission_default"; peer: string; source: string };
60
65
  optionChoice?: string;
@@ -66,6 +71,7 @@ export interface ConsoleState {
66
71
  permissionDefaults: PermissionDefault[];
67
72
  permissionDefaultsHandled: string[];
68
73
  }
74
+ const roomsVisible = (s: Pick<ConsoleState, "rooms">) => s.rooms === null || Array.isArray(s.rooms) && s.rooms.length > 0;
69
75
  /** A detail view: text as the stream shows it, or labeled fields. */
70
76
  export type Detail = string | Record<string, unknown>;
71
77
  export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
@@ -330,7 +336,8 @@ function fieldLines(fields: [string, unknown][], columns: number, now: number):
330
336
  * raw observation and `symbolOverlaps` is the hub pair projection; formatted blocks replace both raw fields.
331
337
  */
332
338
  function taskFields(detail: Record<string, unknown>): [string, unknown][] {
333
- const fields = Object.entries(detail).filter(([key]) => key !== "codeSymbols" && key !== "symbolOverlaps");
339
+ const fields = Object.entries(detail).filter(([key]) => !["codeSymbols", "symbolOverlaps", "plannerReason", "reviewerReason", "ownerReason", "sameFamilyFallback"].includes(key))
340
+ .map(([key, value]): [string, unknown] => [key, key === "planner" || key === "owner" || key === "reviewer" ? taskHolderText(detail, key) : value]);
334
341
  const block = [...symbolFields(detail), ...overlapFields(detail)];
335
342
  if (!block.length) return fields;
336
343
  const at = fields.findIndex(([key]) => key === "plan");
@@ -353,7 +360,8 @@ export function budgetFields(budget: any, now: number): [string, unknown][] {
353
360
  function peerDetailRow(s: ConsoleState, row: any): Record<string, unknown> {
354
361
  const wip = s.taskWip?.[row.id];
355
362
  const split = wip && [wip.active, wip.reviewWait, wip.reserved].every(n => Number.isSafeInteger(n)) ? `${wip.wip} of ${wip.wipLimit}: active ${wip.active}, review-wait ${wip.reviewWait}, reserved ${wip.reserved}` : undefined;
356
- return { ...row, ...(split ? { "planner WIP": split } : {}) };
363
+ const isolation = ownerIsolationText(row.ownerIsolation, row.ownerIsolationNote);
364
+ return { ...row, ...(isolation ? { ownerIsolation: isolation } : {}), ...(split ? { "planner WIP": split } : {}) };
357
365
  }
358
366
  const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
359
367
  if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
@@ -397,9 +405,9 @@ const KEYS: [string, string[]][] = [
397
405
  ["Usage", ["7 Usage panel", "Enter view a peer", "tokens today/7d/lifetime with sources", "task from Tasks (3)"]],
398
406
  ];
399
407
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
400
- export function keyTable(columns: number, attention = false, characters = false): Span[][] {
408
+ export function keyTable(columns: number, attention = false, characters = false, roomsAvailable = false): Span[][] {
401
409
  const lines: Span[][] = []; const pad = 12;
402
- for (const [group, items] of [...KEYS, ...(attention ? [[characters ? "Pet" : "Attention", ["n next need", characters ? "P next character/off" : "P STAI/off"]] as [string, string[]]] : [])]) {
410
+ for (const [group, items] of [...KEYS, ...(roomsAvailable ? [["Rooms", ["8 Rooms panel (read only)", "j/k move", "Enter details"]] as [string, string[]]] : []), ...(attention ? [[characters ? "Pet" : "Attention", ["n next need", characters ? "P next character/off" : "P STAI/off"]] as [string, string[]]] : [])]) {
403
411
  let text = ""; let head = group;
404
412
  const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
405
413
  for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
@@ -449,6 +457,7 @@ export function commandNeedsConfirmation(args: string[]): boolean {
449
457
  }
450
458
  export function panelRows(s: ConsoleState): any[] {
451
459
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
460
+ if (s.panel === 8) return s.rooms ?? [];
452
461
  if (s.panel === 2) return s.approvals;
453
462
  if (s.panel === 3) {
454
463
  if (s.backlogView) return s.tasks.filter(t => t.state === "backlog").sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id);
@@ -475,6 +484,7 @@ const selection = (s: ConsoleState) => {
475
484
  if (s.panel === 2) return s.approvals.findIndex(a => a.id === s.approvalId);
476
485
  if (s.panel === 3 && s.taskSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.taskSelection);
477
486
  if (s.panel === 4 && s.queueSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.queueSelection);
487
+ if (s.panel === 8 && s.roomSelection !== undefined) return panelRows(s).findIndex(row => row.name === s.roomSelection);
478
488
  return s.selection;
479
489
  };
480
490
  export const selectedPanelRow = (s: ConsoleState): any | undefined => panelRows(s)[selection(s)];
@@ -482,6 +492,7 @@ export function captureConsoleSelection(s: ConsoleState, byIndex = false): void
482
492
  const row = byIndex ? panelRows(s)[s.selection] : selectedPanelRow(s);
483
493
  if (s.panel === 3 && row) s.taskSelection = Number(row.id);
484
494
  if (s.panel === 4 && row) s.queueSelection = String(row.id);
495
+ if (s.panel === 8 && row) s.roomSelection = String(row.name);
485
496
  }
486
497
  const commandFlag = (args: string[], name: string) => { const at = args.indexOf(name); return at < 0 ? undefined : args[at + 1]; };
487
498
  function validateLockedCommand(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): void {
@@ -704,8 +715,9 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
704
715
  // The task selected in Tasks is the one the Usage panel shows; switchPanel clears that selection, so it is read first (#683 U3).
705
716
  // With no valid selected row (an empty Tasks panel) the old task is cleared, never kept: the panel must not show a task that is no longer the selection.
706
717
  if (panel === 7 && s.panel === 3) { const task = selectedPanelRow(s); s.usageTask = task && Number.isSafeInteger(Number(task.id)) && Number(task.id) > 0 ? Number(task.id) : undefined; }
707
- s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
718
+ s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.roomSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
708
719
  if (s.mode === "panels" && /^[1-7]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
720
+ if (s.mode === "panels" && key === "8" && !s.optionChoice && roomsVisible(s)) return switchPanel(8);
709
721
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
710
722
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
711
723
  if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel >= 6 ? key === "\x1b[C" ? 1 : 5 : (s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
@@ -801,6 +813,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
801
813
  if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
802
814
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
803
815
  else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
816
+ else if (s.panel === 8) { s.roomSelection = row.name; s.detail = { roomName: row.name }; s.detailOffset = 0; }
804
817
  else { s.detail = s.panel === 5 ? String(row.text) : s.panel === 1 ? peerDetailRow(s, row) : { ...row }; s.detailOffset = 0; }
805
818
  return done();
806
819
  }
@@ -892,12 +905,13 @@ export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now =
892
905
  return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
893
906
  }
894
907
  export const TABLES: Record<number, string[]> = {
895
- 1: ["PEER", "STATE", "LINK", "Q", "!", "WIP", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
908
+ 1: ["PEER", "STATE", "LINK", "Q", "!", "WIP", "REVIEW", "PAUSE", "QUOTA", "MODEL", "DOING"],
896
909
  2: ["ID", "PEER", "LEFT", "TITLE"],
897
910
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
898
911
  4: ["ID", "PEER", "STATE", "REV", "AGE"],
899
912
  6: ["GROUP", "KEY", "VALUE", "SOURCE", "APPLIES"],
900
913
  7: USAGE_HEAD,
914
+ 8: ["ROOM", "LINK", "MEMBERS", "REMOTE"],
901
915
  };
902
916
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
903
917
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
@@ -906,6 +920,9 @@ const waitingDeps = (s: ConsoleState, row: any): number[] => (row.deps ?? []).fi
906
920
  /** One span per column of a panel row; zero counters and unknown values read `-`. */
907
921
  export function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
908
922
  const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
923
+ if (s.panel === 8) return [id(row.name, "info"), span(row.link ?? "unknown", row.link ? stateTone(row.link) : "muted"),
924
+ span(row.memberCount === null ? "unknown" : String(row.memberCount), row.memberCount === null ? "muted" : undefined),
925
+ span(row.remotePeers === null ? "unknown" : String(row.remotePeers.length), row.remotePeers === null ? "muted" : undefined)];
909
926
  if (s.panel === 1) {
910
927
  const b = s.budget[row.id];
911
928
  const wip = s.taskWip?.[row.id];
@@ -919,7 +936,8 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
919
936
  span(wip ? `${wip.wip}/${wip.wipLimit}` : "-", wip && wip.wip >= wip.wipLimit ? "attention" : undefined),
920
937
  span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
921
938
  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(", ") : "-"),
922
- span(row.servedBy ?? row.requestedModel ?? "-")];
939
+ span(row.servedBy ?? row.requestedModel ?? "-"),
940
+ span(typeof row.doing?.text === "string" && row.doing.text ? row.doing.text : "-", row.doing?.text === DOING_STUB ? "muted" : undefined)]; // #878: screened by the hub
923
941
  }
924
942
  if (s.panel === 7) {
925
943
  const text = usageCells(s.usage, row.id, s.budget[row.id], at => relative(at, now));
@@ -947,6 +965,21 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
947
965
  // stream. Colour off concatenates to the same bytes.
948
966
  return [...streamTokens((header ?? "").replace(/\t/g, " "), row.tone, row.kind), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
949
967
  }
968
+
969
+ function roomDetail(row: RoomStatusView): Record<string, unknown> {
970
+ const relays = row.relays;
971
+ let since = "unknown";
972
+ if (relays) try { const date = new Date(relays.since); if (Number.isFinite(date.getTime())) since = date.toISOString(); } catch { /* keep unknown */ }
973
+ const count = (value: number | null | undefined) => typeof value === "number" ? String(value) : "unknown";
974
+ return {
975
+ room: row.name, "room id": row.room, "link state": row.link ?? "unknown", owner: row.owner, "local member": row.member,
976
+ "member count": count(row.memberCount),
977
+ "remote peers": row.remotePeers === null ? "unknown" : row.remotePeers.length ? row.remotePeers.map(peer => `${peer.id} (${peer.state})`) : "0",
978
+ "counter scope": `since daemon start: ${since}`,
979
+ "out handoff": count(relays?.outbound), "in admitted": count(relays?.inbound), withheld: count(relays?.withheld),
980
+ };
981
+ }
982
+
950
983
  /**
951
984
  * A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
952
985
  * widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
@@ -975,10 +1008,10 @@ export function table(head: string[], rows: Span[][], columns: number, explicitW
975
1008
  }));
976
1009
  }
977
1010
  /** Keep the status/quota columns visible instead of clipping them behind optional peer metadata. */
978
- function optionalTable(head: string[], rows: Span[][], columns: number, drop = ["MODEL", "WIP", "PAUSE", "REVIEW", "LINK", "Q", "!", "MODE"]): Span[][] {
1011
+ function optionalTable(head: string[], rows: Span[][], columns: number, drop = ["DOING", "MODEL", "WIP", "PAUSE", "REVIEW", "LINK", "Q", "!", "MODE"]): Span[][] {
979
1012
  const cap = Math.max(24, Math.floor(columns / 3));
980
1013
  const widths = head.map((name, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, Bun.stringWidth(flat(row[i]?.text ?? ""))), name.length)));
981
- const keep = head.map((_, i) => i);
1014
+ const keep = head.map((_, i) => i).filter(i => head[i] !== "DOING" || rows.some(row => row[i]?.text !== "-")); // #878: shown once a peer has one
982
1015
  const total = () => keep.reduce((sum, i) => sum + widths[i]!, 0) + 2 * (keep.length - 1);
983
1016
  for (const name of drop) {
984
1017
  if (total() <= columns - 2) break;
@@ -1033,16 +1066,17 @@ function hint(s: ConsoleState, columns: number): string {
1033
1066
  const compact = text.replace("m permission mode", "m mode").replace("w WIP limit", "w WIP").replace("j/k move", "j/k").replace(": command", ": cmd");
1034
1067
  return Bun.stringWidth(compact) > columns ? compact.replace("Enter view", "Enter") : compact;
1035
1068
  }
1036
- /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
1069
+ /** The project, then the tabs: Rooms appears only when the hub reports room metadata. */
1037
1070
  function header(s: ConsoleState, columns: number): Span[] {
1038
1071
  const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
1039
1072
  const project = s.project ?? "agent-hub";
1040
- // Seven tabs fill an 80-column line, which would squeeze the project name out: below that, one space before a tab instead of two (#683 U3).
1073
+ // The tabs can fill an 80-column line, which would squeeze the project name out: below that, one space before a tab instead of two (#683 U3).
1041
1074
  const build = (lead: string) => {
1042
1075
  const tabs: Span[] = [];
1043
- for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings", "Usage"].entries()) {
1044
- const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
1045
- tabs.push(span(active ? lead.slice(1) + "[" : lead, tone), span(`${i + 1} ${name}`, tone));
1076
+ const names = ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings", "Usage", ...(roomsVisible(s) ? ["Rooms"] : [])];
1077
+ for (const [i, name] of names.entries()) {
1078
+ const number = i === 7 ? 8 : i + 1, active = number === s.panel, tone = active ? "strong" : "info";
1079
+ tabs.push(span(active ? lead.slice(1) + "[" : lead, tone), span(`${number} ${name}`, tone));
1046
1080
  if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
1047
1081
  tabs.push(span(active ? "]" : " ", tone));
1048
1082
  }
@@ -1152,10 +1186,13 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1152
1186
  const definition: [string, unknown][] = row ? [["type", row.type], ...(row.min !== undefined || row.max !== undefined ? [["range", `${row.min ?? "-"}..${row.max ?? "-"}`] as [string, unknown]] : []), ["risk", row.risk], ["applies", row.applies]] : [];
1153
1187
  lines.push(...fieldLines([["setting", edit.key ?? s.settingsUndo ?? "last write"], ...definition, ["preview", (edit.lines ?? []).join("\n")], ["decision", "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
1154
1188
  }
1155
- else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
1189
+ else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length, roomsVisible(s)));
1156
1190
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
1157
1191
  const usagePeer = s.panel === 7 && s.detail && typeof s.detail === "object" ? (s.detail as { usagePeer?: unknown }).usagePeer : undefined;
1158
- const detail = typeof usagePeer === "string" ? fieldLines(Object.entries(usageDetail(s.usage, usagePeer, s.budget[usagePeer], at => relative(at, now))), columns, now).flat()
1192
+ const roomName = s.panel === 8 && s.detail && typeof s.detail === "object" ? (s.detail as { roomName?: unknown }).roomName : undefined;
1193
+ const room = typeof roomName === "string" && Array.isArray(s.rooms) ? s.rooms.find(row => row.name === roomName) : undefined;
1194
+ const detail = typeof usagePeer === "string" ? fieldLines(Object.entries(usageDetail(s.usage, usagePeer, s.budget[usagePeer], at => relative(at, now))), columns, now).flat()
1195
+ : typeof roomName === "string" ? detailLines(room ? roomDetail(room) : { room: roomName, status: "unavailable" }, columns, now)
1159
1196
  : s.detail !== undefined ? detailLines(s.detail, columns, now, s.panel === 3) : fieldLines(approvalFields(permission!), columns, now).flat();
1160
1197
  lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
1161
1198
  }
@@ -1172,7 +1209,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1172
1209
  let taskLines = s.panel === 7 ? usageTaskLines(s.usage, s.usageTask) : [];
1173
1210
  const taskRoom = Math.max(1, Math.floor(height / 2));
1174
1211
  if (taskLines.length > taskRoom) taskLines = [...taskLines.slice(0, taskRoom - 1), { text: ` ... ${taskLines.length - taskRoom + 1} more`, tone: "muted" as const }];
1175
- const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : s.panel === 7 ? height - 2 - taskLines.length : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
1212
+ const selectedTask = s.panel === 3 ? data[at] : undefined;
1213
+ const holders = selectedTask && (selectedTask.planner !== undefined || selectedTask.plannerReason !== undefined)
1214
+ ? fieldLines((["planner", "owner", "reviewer"] as const).map((role): [string, unknown] => [role, taskHolderText(selectedTask, role)]), columns, now).flat() : [];
1215
+ const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : s.panel === 7 ? height - 2 - taskLines.length : height - (s.panel === 3 ? 2 + holders.length : 1)); const start = Math.max(0, at - capacity + 1);
1176
1216
  const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
1177
1217
  const cellRows = head ? data.map((row, i) => cells(s, row, i === at, now, stages)) : [];
1178
1218
  const rowsOf = head ? s.panel === 7 ? optionalTable(head, cellRows, columns, USAGE_DROP) : s.panel === 1 ? optionalTable(head, cellRows, columns) : s.panel === 3 && s.backlogView ? optionalTable(head, cellRows, columns, ["AGE", "CLASS", "DEPS", "PRI"]) : table(head, cellRows, columns) : undefined;
@@ -1191,6 +1231,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1191
1231
  : `usage through ${s.usage.at ? relative(s.usage.at, now) : "now"}: today is the UTC day, 7d the last seven UTC days`;
1192
1232
  lines.push(fitLine([span(note, s.usage?.problem ? "failure" : !s.usage?.life || s.usage.behind ? "attention" : "info")], columns));
1193
1233
  }
1234
+ if (s.panel === 8 && s.rooms === null) lines.push(fitLine([span("Room status unavailable", "attention")], columns));
1194
1235
  if (s.panel === 3 && s.backlogView) {
1195
1236
  lines.push(fitLine([span(s.tasksKnown === false ? "backlog loading..." : `backlog ${data.length}`, s.tasksKnown === false ? "attention" : "info")], columns));
1196
1237
  } else if (s.panel === 3) {
@@ -1211,6 +1252,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1211
1252
  lines.push(fitLine([span(selected ? "> " : " ", selected ? "strong" : undefined), ...(rowsOf?.[i + 1] ?? cells(s, data[i], selected, now))], columns));
1212
1253
  }
1213
1254
  if (!data.length) lines.push([span(" (empty)")]);
1255
+ if (holders.length) lines.push(...holders);
1214
1256
  if (s.panel === 7) for (const line of taskLines) lines.push(fitLine([span(" " + line.text, line.tone)], columns));
1215
1257
  if (title.length) {
1216
1258
  const room = Math.max(1, height - lines.length - 1 - tail.length);
@@ -13,6 +13,7 @@ import { loadPetConfig } from "./pet-config.ts";
13
13
  import { CHARACTER_IDS, petGlyphs, petSize } from "../pet/frame.ts";
14
14
  import { activeNotice, consoleFooterRows } from "./console-state.ts";
15
15
  import { characterAttentionLine } from "./attention.ts";
16
+ import { projectRoomStatus } from "../rooms/status.ts";
16
17
 
17
18
  export interface ConsoleTerminal {
18
19
  columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
@@ -218,7 +219,21 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
218
219
  if (status.status && status.ok !== false) state.pendingRestart = Array.isArray(status.status.pendingRestart) ? status.status.pendingRestart : [];
219
220
  if (status.status && status.ok !== false) state.retainedConfig = Array.isArray(status.status.retainedConfig) ? status.status.retainedConfig : [];
220
221
  if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
221
- if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
222
+ if (status.status && status.ok !== false) {
223
+ const rooms = status.status.rooms;
224
+ state.rooms = rooms === undefined ? undefined : rooms === null ? null : Array.isArray(rooms) ? projectRoomStatus(rooms).sort((a, b) => a.name.localeCompare(b.name)) : null;
225
+ if (state.rooms === undefined || Array.isArray(state.rooms) && state.rooms.length === 0) {
226
+ if (state.panel === 8) { state.panel = 1; state.selection = 0; state.roomSelection = undefined; state.detail = undefined; state.detailOffset = 0; }
227
+ } else if (state.panel === 8) {
228
+ if (state.rooms === null) { state.roomSelection = undefined; state.selection = 0; state.detail = undefined; state.detailOffset = 0; }
229
+ else if (!state.roomSelection || !state.rooms.some(room => room.name === state.roomSelection)) {
230
+ state.roomSelection = state.rooms[0]?.name; state.selection = 0; state.detail = undefined; state.detailOffset = 0;
231
+ }
232
+ }
233
+ }
234
+ if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer],
235
+ ...(typeof status.status.ownerIsolation?.[peer] === "boolean" ? { ownerIsolation: status.status.ownerIsolation[peer] } : {}),
236
+ ...(typeof status.status.ownerIsolationNotes?.[peer] === "string" ? { ownerIsolationNote: status.status.ownerIsolationNotes[peer] } : {}) }]));
222
237
  if (status.status && status.ok !== false) {
223
238
  state.taskWip = status.status.taskWip ?? {};
224
239
  state.attentionFacts = attentionFacts(status.status.attentionItems);
@@ -330,7 +345,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
330
345
  if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
331
346
  return;
332
347
  }
333
- if (action.type === "keys") return stream({ text: keyTable(columns, !!state.petEnabled || !!state.petChoices?.length, !!state.petChoices?.length).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
348
+ if (action.type === "keys") return stream({ text: keyTable(columns, !!state.petEnabled || !!state.petChoices?.length, !!state.petChoices?.length, state.rooms === null || Array.isArray(state.rooms) && state.rooms.length > 0).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
334
349
  if (action.type === "print") return stream({ text: action.text, kind: action.kind, ...(action.tone ? { tone: action.tone } : {}) }, false);
335
350
  if (action.type === "show") {
336
351
  const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
@@ -94,7 +94,9 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
94
94
  // A present value that is not an empty list asks, even when malformed: the hook never turns an unreadable classification into an allow.
95
95
  const classed = res.actionClasses !== undefined && !(Array.isArray(res.actionClasses) && res.actionClasses.length === 0);
96
96
  const classNames = Array.isArray(res.actionClasses) ? res.actionClasses.filter((key: unknown): key is string => typeof key === "string" && /^[a-z-]{1,32}$/.test(key)) : [];
97
- const allow = !classed && input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
97
+ // The daemon supplies this ceiling from the authenticated instance's flow roles, never a peer-id spelling.
98
+ const mayGrant = !Object.hasOwn(res, "mutationGrants") || res.mutationGrants === true || ["Read", "LS", "Glob", "Grep"].includes(String(input.tool_name));
99
+ const allow = mayGrant && !classed && input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
98
100
  const text = typeof res.text === "string" && res.text ? res.text : undefined;
99
101
  if (!allow && !classed && !text) return undefined;
100
102
  return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(classed ? { permissionDecision: "ask", permissionDecisionReason: `agent-hub always-ask${classNames.length ? `: ${classNames.join(", ")}` : ""}` } : allow ? { permissionDecision: "allow" } : {}) } });