@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
package/src/cli/output.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { QueueBackpressureStatus } from "../hub/queue-backpressure.ts";
1
2
  import { definitionOf } from "../adapters/registry.ts";
2
3
  import { windowKind } from "../hub/window-kind.ts";
3
4
  import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
@@ -5,17 +6,21 @@ import { peerStyle } from "../ui/peer-palette.ts";
5
6
  import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
6
7
  import { taskProgress } from "../ui/task-progress.ts";
7
8
  import { backendLabel } from "./status-lines.ts";
9
+ import { ownerIsolationText } from "./role-holders.ts";
8
10
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
9
11
  import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
10
12
 
11
13
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
12
14
  export interface StatusOutput {
15
+ queueBackpressure?: QueueBackpressureStatus;
13
16
  pendingRestart?: readonly { key: string; reason: string }[];
14
17
  retainedConfig?: readonly { key: string; reason: string }[];
15
18
  /** #572 AC3: advisory, present while the project root checkout is on a branch other than the default. */
16
19
  mainCheckout?: string;
17
20
  /** #843 AC3: installed agent CLIs not on a verified version; ids, version, state and a receipt path only. */
18
21
  agentDrift?: readonly { id: string; version: string; state: string; receipt: string }[];
22
+ /** #512a: one row per machine-local schedule; ids, times and a state only, never task text. */
23
+ schedules?: readonly { id: string; every: string; class: string; queue: string; next: number; last?: number; task?: number; state: string; coalesced?: number; error?: string }[];
19
24
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
20
25
  pendingTaskOffers?: readonly { task: number; peer: string }[];
21
26
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
@@ -24,6 +29,7 @@ export interface StatusOutput {
24
29
  /** #784 AC3: `active`, `reviewWait` and `reserved` add up to `wip`; absent from an older hub. */
25
30
  taskWip?: Record<string, { wip: number; wipLimit: number; active?: number; reviewWait?: number; reserved?: number }>;
26
31
  pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
32
+ ownerIsolation?: Record<string, boolean>; ownerIsolationNotes?: Record<string, string>;
27
33
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
28
34
  }
29
35
  /** `peers`: one note per peer that shows no number, and why (no quota, nothing read yet); absent from an older hub. */
@@ -211,6 +217,20 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
211
217
  out.push(...detail("config retained", `${data.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, columns, "attention"));
212
218
  for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
213
219
  }
220
+ for (const row of data.schedules ?? []) {
221
+ const when = (at: number) => new Date(at).toISOString().replace("T", " ").slice(0, 16) + "Z";
222
+ const text = `${row.state === "paused" ? `paused (ahub schedule resume ${row.id}); would be next ${when(row.next)}` : row.state === "scheduled" ? `next ${when(row.next)}` : row.state === "due" ? "due now" : row.state === "waiting" ? `due, waiting for task #${row.task} to close` : "due, held (the hub is stopping or recovering)"}; every ${row.every}; ${row.class} to ${row.queue}${row.last ? `; last ${when(row.last)}${row.task ? ` as task #${row.task}` : ""}` : "; never run"}${row.coalesced ? `; ${row.coalesced} skipped run${row.coalesced === 1 ? "" : "s"} coalesced` : ""}${row.error ? `; last error: ${row.error}` : ""}`;
223
+ out.push(...detail(`schedule ${row.id}`, text, columns, row.error ? "attention" : undefined));
224
+ }
225
+ if (data.queueBackpressure) {
226
+ const pressure = data.queueBackpressure, policy = pressure.policy;
227
+ out.push(...detail("queue backpressure", `max queued ${policy.max_queued || "disabled"}; max oldest age ${policy.max_oldest_s ? `${policy.max_oldest_s}s` : "disabled"}; explicit assignment overrides only that role`, columns));
228
+ for (const [peer, row] of Object.entries(pressure.peers)) {
229
+ if (row.queued === 0 && !row.blocked) continue;
230
+ const age = typeof row.oldestAgeMs === "number" && Number.isFinite(row.oldestAgeMs) && row.oldestAgeMs >= 0 ? duration(row.oldestAgeMs) : "unknown";
231
+ out.push(...detail(`${peer} queue pressure`, `queued ${row.queued ?? "unknown"}; oldest ${age}${row.blocked ? `; automatic offers held: ${row.reason ?? row.code ?? "unknown"}` : ""}`, columns, row.blocked ? "attention" : undefined));
232
+ }
233
+ }
214
234
  for (const stall of data.taskStalls ?? []) out.push(...detail(`task #${stall.task} stalled`, `${stall.cause.kind}${stall.cause.peer ? ` ${stall.cause.peer}` : ""}${stall.cause.dependency ? ` #${stall.cause.dependency}` : ""}: ${stall.cause.code}; configure the peer, ahub task assign ${stall.task} <peer>, or ahub task cancel ${stall.task} --reason <text>`, columns, "attention"));
215
235
  for (const offer of data.pendingTaskOffers ?? []) out.push(...detail("task offer", `open ${offer.peer}: task #${offer.task} is waiting for it`, columns, "attention"));
216
236
  if (data.memoryProfile) {
@@ -220,6 +240,10 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
220
240
  }
221
241
  if (data.memoryRead?.truncated) out.push(...detail("memory view", `truncated: newest ${data.memoryRead.limit} project candidates considered`, columns, "attention"));
222
242
  if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
243
+ for (const [id, isolated] of Object.entries(data.ownerIsolation ?? {})) {
244
+ const text = ownerIsolationText(isolated, data.ownerIsolationNotes?.[id]);
245
+ if (text) out.push(...detail(`${id} owner isolation`, text, columns, isolated ? undefined : "attention"));
246
+ }
223
247
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
224
248
  for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
225
249
  for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
@@ -236,6 +260,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
236
260
  let settling = false;
237
261
  const peerDetails = Object.entries(data.peers).map(([id, p]) => {
238
262
  const out: Span[][] = [];
263
+ if (typeof p.form === "string") out.push(...detail(`${id} form`, p.form, columns));
264
+ if (Array.isArray(p.capableRoles)) out.push(...detail(`${id} capable roles`, p.capableRoles.join(", ") || "none", columns));
239
265
  if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
240
266
  if (p.catalogRoles) { out.push(...detail(`${id} catalog roles`, Array.isArray(p.catalogRoles.value) ? p.catalogRoles.value.join(", ") || "none" : "unconfirmed", columns)); if (p.catalogRoles.pending !== undefined) out.push(...detail(`${id} pending roles`, p.catalogRoles.pending.join(", ") || "none", columns, "attention")); }
241
267
  if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
@@ -448,8 +474,11 @@ export function renderTurns(turns: any[], columns?: number, now = Date.now()): S
448
474
  return rows(["TURN", "PEER", "STARTED", "STATE", "FILES"], turns.map(turn => {
449
475
  const state = !turn.ended ? "running" : !turn.end_tree ? "no end snapshot" : "completed";
450
476
  const files = turn.changed ?? [];
477
+ // #798 D2: a dirty submodule is listed beside the files, tagged, so a turn that edited only a submodule never reads "0 files".
478
+ const unobserved: string[] = Array.isArray(turn.unobserved) ? turn.unobserved.filter((path: unknown): path is string => typeof path === "string") : [];
479
+ const shown = [...files, ...unobserved.map(path => `${path} (changed, contents unobserved)`)];
451
480
  return [span(turn.id, "info"), span(turn.peer ?? "unknown", peerTone(turn.peer)), span(moment(turn.started, now), "muted"),
452
- span(state, state === "running" ? "attention" : state === "no end snapshot" ? "failure" : "success"), { ...span(`${plural(files.length, "file")}${files.length ? `: ${files.join(", ")}` : ""}`), ...(files.length ? { items: files, pathItems: true, prefix: `${plural(files.length, "file")}:` } : {}) }];
481
+ span(state, state === "running" ? "attention" : state === "no end snapshot" ? "failure" : "success"), { ...span(`${plural(files.length, "file")}${shown.length ? `: ${shown.join(", ")}` : ""}`), ...(shown.length ? { items: shown, pathItems: true, prefix: `${plural(files.length, "file")}:` } : {}) }];
453
482
  }), columns);
454
483
  }
455
484
  export function renderOrphans(orphans: { project: any; pids: number[] }[], columns?: number, _now = Date.now(), killing = false): Span[][] {
@@ -459,6 +488,17 @@ export function renderOrphans(orphans: { project: any; pids: number[] }[], colum
459
488
  ...(killing ? [] : [[], ...lines("kill live orphans with ahub doctor --orphans --kill", columns)]),
460
489
  ...orphans.flatMap(({ project }) => detail("forget", `ahub projects remove ${project.id}`, columns))];
461
490
  }
491
+ /** `ahub doctor --orphans --kill` from the conductor's shell (#812, #899): the hub inspects and stops only its own positively identified process; the shell prints what it answered. */
492
+ export function renderOrphanCleanup(result: unknown, columns?: number, _now = Date.now()): Span[][] {
493
+ const r = result as any;
494
+ const stopped: number[] | undefined = Array.isArray(r?.stopped) ? r.stopped.filter((pid: unknown) => typeof pid === "number") : undefined;
495
+ const out = stopped === undefined ? lines("stopped processes unknown", columns, "attention")
496
+ : stopped.length ? lines(`stopped ${stopped.length} orphaned hub process${stopped.length === 1 ? "" : "es"}: ${stopped.join(", ")}`, columns, "success")
497
+ : lines("no orphaned hub process to stop", columns, "muted");
498
+ if (r?.self === true) out.push(...lines("this hub was itself an orphan and is stopping", columns, "attention"));
499
+ if (r?.unknown === true) out.push(...lines("a candidate could not be confirmed stopped; a person can inspect it with ahub doctor --orphans", columns, "attention"));
500
+ return out;
501
+ }
462
502
  const TIME_FIELD = /^(?:at|ts|started|ended|created|updated|expires)$|(?:At|Until)$/;
463
503
  const ID_FIELD = /^(?:id|trace|previousId|sessionId|threadId|operationId|turnId|envelopeIds)$/;
464
504
  /** Labelled scalar leaves preserve the daemon's public view without a raw document or inferred privacy changes. */
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { createRequire } from "node:module";
6
6
  import { hubHome, realPath } from "../hub/project.ts";
7
7
 
@@ -105,6 +105,23 @@ export function verifyPackage(root: string, version: string, options: { ownDepen
105
105
  return packageDigest(root, options);
106
106
  }
107
107
 
108
+ /** Preserved restart coordinators own their dependency; staged upgrades use the install's hoisted dependency. */
109
+ export function recoveryPackageOptions(kind: "restart" | "upgrade", root: string): { ownDependencies?: boolean; dependencyRoot?: string } {
110
+ if (kind === "restart") return { ownDependencies: true };
111
+ if (kind !== "upgrade") throw new Error("unsupported recovery package kind");
112
+ const canonical = realPath(root), dependencyRoot = resolve(canonical, "../../..");
113
+ if (canonical !== root || canonical !== join(dependencyRoot, "node_modules", "@staix", "agent-hub")) throw new Error("invalid staged recovery package path");
114
+ return { dependencyRoot };
115
+ }
116
+
117
+ export function recoveryTargetDigest(kind: "restart" | "upgrade", root: string): string {
118
+ return packageDigest(root, recoveryPackageOptions(kind, root));
119
+ }
120
+
121
+ export function verifyRecoveryTarget(kind: "restart" | "upgrade", root: string, version: string): string {
122
+ return verifyPackage(root, version, recoveryPackageOptions(kind, root));
123
+ }
124
+
108
125
  export async function registryRelease(version: string, run: RunCommand = runCommand): Promise<{ version: string; integrity: string }> {
109
126
  exactVersion(version);
110
127
  const result = await run(["npm", "view", `@staix/agent-hub@${version}`, "version", "dist.integrity", "--json"], { timeoutMs: 30_000 });
@@ -0,0 +1,35 @@
1
+ /** Read recorded task-holder metadata only; presentation never chooses a holder or parses note text. */
2
+ export type TaskHolderRole = "planner" | "owner" | "reviewer";
3
+ const word = (value: unknown): string | undefined => typeof value === "string" && value ? value : undefined;
4
+ const entries = (task: any): Record<string, unknown>[] => Array.isArray(task?.history)
5
+ ? task.history.filter((entry: unknown): entry is Record<string, unknown> => !!entry && typeof entry === "object" && !Array.isArray(entry)) : [];
6
+
7
+ export function taskHolderText(task: any, role: TaskHolderRole): string {
8
+ const holder = word(task?.[role]);
9
+ if (!holder) return role === "owner" && word(task?.reserved) ? `${task.reserved} (reserved)` : "-";
10
+ const history = entries(task);
11
+ const reasons: string[] = [];
12
+ if (role === "planner") {
13
+ const recorded = history.findLast(entry => entry.planner === holder && word(entry.plannerReason));
14
+ const reason = word(task?.plannerReason) ?? word(recorded?.plannerReason);
15
+ if (reason) reasons.push(reason);
16
+ } else if (role === "reviewer") {
17
+ const recorded = history.findLast(entry => ["assigned", "reassigned", "escalated", "done", "reviewer changed", "reviewer unavailable"].includes(String(entry.event)));
18
+ const reason = word(task?.reviewerReason) ?? word(recorded?.reviewerReason) ?? (recorded?.event === "reviewer changed" && recorded.reason === "manual" ? "named" : undefined);
19
+ if (reason) reasons.push(reason);
20
+ if (task?.sameFamilyFallback === true && !reasons.includes("same-family fallback")) reasons.push("same-family fallback");
21
+ } else {
22
+ const recorded = history.findLast(entry => entry.owner === holder && ["assigned", "reassigned", "escalated", "backlog pulled"].includes(String(entry.event)));
23
+ const reason = word(task?.ownerReason) ?? (recorded?.reason === "manual" && recorded.event === "reassigned" && word(recorded.by) && recorded.by !== "hub" ? "named" : word(recorded?.reason));
24
+ if (reason) reasons.push(reason);
25
+ if (task?.reserved === holder && !reasons.includes("reserved")) reasons.push("reserved");
26
+ }
27
+ return `${holder}${reasons.length ? ` (${reasons.join("; ")})` : ""}`;
28
+ }
29
+
30
+ /** The daemon's per-extra boolean stays boolean on the wire; an absent proof is not a false proof. */
31
+ export function ownerIsolationText(isolated: unknown, reason?: unknown): string | undefined {
32
+ if (isolated === true) return "isolated";
33
+ if (isolated === false) return `not isolated: ${word(reason) ?? "execution root not verified"}`;
34
+ return undefined;
35
+ }
@@ -0,0 +1,304 @@
1
+ import { decodeBase64url } from "../rooms/crypto.ts";
2
+ import { roomReceiptReasonText, type RoomReceiptReason } from "../rooms/application.ts";
3
+ import { frame, type Envelope } from "../hub/envelope.ts";
4
+ import { terminalText } from "./console-state.ts";
5
+ import type { RoomPersonInboxMeta, RoomPersonInboxItem } from "../rooms/store.ts";
6
+ /** Membership mutations and inbox forms are person-only; main.ts refuses agent/live-test calls before importing this module. */
7
+ export const ROOM_USAGE = "usage: ahub room create <name> --broker <url> [--label <id>] | invite <name> [--accept <request>] | join <token> [--name <name>] [--label <id>] | join <name> --roster <signed-roster> | leave <name> | revoke <name> <fingerprint> | expose <name> [<peer>...] | resolve <name> --frame <digest> --action accept|discard (membership mutations and resolve require TTY y/N or --confirm <member-fingerprint-prefix>, 16..64 lowercase hex) | list [--json] | status [<name>] [--json] | inbox [<name>] [--json] | inbox show|done|discard <id>";
8
+
9
+ type RoomAction = "create" | "invite" | "join" | "leave" | "revoke" | "expose" | "resolve";
10
+ export type RoomCliCommand =
11
+ | { op: "list" | "status"; input: Record<string, unknown>; json: boolean }
12
+ | { op: "inbox"; action: "list"; input: { name?: string }; json: boolean }
13
+ | { op: "inbox"; action: "show" | "done" | "discard"; input: { id: string } }
14
+ | { op: "mutation"; action: RoomAction; input: Record<string, unknown>; confirm?: string };
15
+ export type RoomRequest = (message: Record<string, unknown>) => Promise<any>;
16
+ /** Fixed host/test I/O only, never a callback selected by project configuration. */
17
+ export interface RoomConfirmationIO { isTTY(): boolean; answer(prompt: string): Promise<string> }
18
+
19
+ export class RoomCliError extends Error {
20
+ constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch") {
21
+ const message = { refused: "operation refused", invalid_reply: "invalid control reply", inbox_list_limit: "inbox list exceeds the limit; choose a room name", confirmation_required: "confirmation required; use a TTY or --confirm with the member fingerprint prefix", not_confirmed: "not confirmed; nothing executed", confirmation_mismatch: "confirmation does not match the member fingerprint; nothing executed" };
22
+ super(code === "usage" ? ROOM_USAGE : `room: ${message[code]}`);
23
+ this.name = "RoomCliError";
24
+ }
25
+ }
26
+ function fail(code: ConstructorParameters<typeof RoomCliError>[0] = "usage"): never { throw new RoomCliError(code); }
27
+ const ID = /^[a-z][a-z0-9-]{0,31}$/;
28
+ const FP = /^[0-9a-f]{64}$/;
29
+ const B64 = /^[A-Za-z0-9_-]+$/;
30
+ function id(value: string | undefined): string { if (!value || !ID.test(value)) fail(); return value; }
31
+ function artifact(value: string | undefined): string {
32
+ if (!value || value.length > 699_051 || !B64.test(value) || value.length % 4 === 1) fail();
33
+ return value;
34
+ }
35
+ function flags(args: readonly string[], allowed: readonly string[], dashValues: readonly string[] = []): Map<string, string> {
36
+ const result = new Map<string, string>();
37
+ for (let i = 0; i < args.length; i += 2) {
38
+ const key = args[i], value = args[i + 1];
39
+ if (!key || !allowed.includes(key) || !value || value.startsWith("--") && !dashValues.includes(key) || result.has(key)) fail();
40
+ result.set(key, value);
41
+ }
42
+ return result;
43
+ }
44
+
45
+ /** The parser carries no authorization, filesystem or network effects. The daemon validates every value again. */
46
+ function parseRoomInput(args: readonly string[]): RoomCliCommand {
47
+ const [action, operand, ...rest] = args;
48
+ if (action === "list" || action === "status") {
49
+ const json = args.includes("--json"), values = args.slice(1).filter(value => value !== "--json");
50
+ if (args.filter(value => value === "--json").length > 1 || values.length > (action === "status" ? 1 : 0)) fail();
51
+ return { op: action, input: values.length ? { name: id(values[0]) } : {}, json };
52
+ }
53
+ if (args.includes("--json")) fail(); // Invitation secrets are never a JSON data format.
54
+ if (action === "resolve") {
55
+ const name = id(operand), options = flags(rest, ["--frame", "--action"], ["--frame"]), frame = options.get("--frame"), choice = options.get("--action");
56
+ if (!frame || frame.length !== 43 || (choice !== "accept" && choice !== "discard")) fail();
57
+ try { decodeBase64url(frame, 32); } catch { fail(); }
58
+ return { op: "mutation", action, input: { name, frame, action: choice } };
59
+ }
60
+ if (action === "create") {
61
+ const name = id(operand), options = flags(rest, ["--broker", "--label"]), broker = options.get("--broker");
62
+ if (!broker || broker.length > 2048) fail();
63
+ return { op: "mutation", action, input: { name, broker, ...(options.has("--label") ? { label: id(options.get("--label")) } : {}) } };
64
+ }
65
+ if (action === "invite") {
66
+ const name = id(operand), options = flags(rest, ["--accept"]);
67
+ return { op: "mutation", action, input: { name, ...(options.has("--accept") ? { request: artifact(options.get("--accept")) } : {}) } };
68
+ }
69
+ if (action === "join") {
70
+ const options = flags(rest, ["--roster", "--name", "--label"]);
71
+ if (options.has("--roster")) {
72
+ if (options.size !== 1) fail();
73
+ return { op: "mutation", action, input: { name: id(operand), roster: artifact(options.get("--roster")) } };
74
+ }
75
+ return { op: "mutation", action, input: { token: artifact(operand), ...(options.has("--name") ? { name: id(options.get("--name")) } : {}), ...(options.has("--label") ? { label: id(options.get("--label")) } : {}) } };
76
+ }
77
+ if (action === "leave") {
78
+ if (rest.length) fail();
79
+ return { op: "mutation", action, input: { name: id(operand) } };
80
+ }
81
+ if (action === "revoke") {
82
+ if (rest.length !== 1 || !FP.test(rest[0]!)) fail();
83
+ return { op: "mutation", action, input: { name: id(operand), member: rest[0] } };
84
+ }
85
+ if (action === "expose") {
86
+ const peers = rest.map(value => id(value));
87
+ if (peers.length > 64 || new Set(peers).size !== peers.length) fail();
88
+ return { op: "mutation", action, input: { name: id(operand), peers } };
89
+ }
90
+ return fail();
91
+ }
92
+
93
+ export function parseRoomCommand(args: readonly string[]): RoomCliCommand {
94
+ if (args[0] === "inbox") {
95
+ const values = args.slice(1).filter(value => value !== "--json"), json = args.includes("--json");
96
+ if (args.filter(value => value === "--json").length > 1) fail();
97
+ if (values.length === 2) {
98
+ const [action, value] = values;
99
+ if (json || (action !== "show" && action !== "done" && action !== "discard") || !value || value.length !== 36 || !UUID.test(value)) fail();
100
+ return { op: "inbox", action, input: { id: value } };
101
+ }
102
+ if (values.length > 1) fail();
103
+ return { op: "inbox", action: "list", input: values.length ? { name: id(values[0]) } : {}, json };
104
+ }
105
+ if (args[0] === "list" || args[0] === "status") return parseRoomInput(args);
106
+ let confirm: string | undefined;
107
+ const input: string[] = [];
108
+ for (let i = 0; i < args.length; i++) {
109
+ if (args[i] !== "--confirm") { input.push(args[i]!); continue; }
110
+ const value = args[++i];
111
+ if (confirm !== undefined || !value || !/^[0-9a-f]{16,64}$/.test(value)) fail();
112
+ confirm = value;
113
+ }
114
+ const command = parseRoomInput(input);
115
+ if (command.op !== "mutation") return fail();
116
+ return { ...command, ...(confirm !== undefined ? { confirm } : {}) };
117
+ }
118
+
119
+ const confirmationIO: RoomConfirmationIO = {
120
+ isTTY: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
121
+ answer: async prompt => {
122
+ const { createInterface } = await import("node:readline/promises");
123
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
124
+ const controller = new AbortController(), abort = () => controller.abort();
125
+ terminal.once("close", abort); terminal.once("SIGINT", abort);
126
+ try { return await terminal.question(prompt, { signal: controller.signal }); }
127
+ finally { terminal.off("close", abort); terminal.off("SIGINT", abort); terminal.close(); }
128
+ },
129
+ };
130
+ async function confirmMember(member: string, explicit: string | undefined, io: RoomConfirmationIO): Promise<string> {
131
+ if (explicit !== undefined) {
132
+ if (!member.startsWith(explicit)) fail("confirmation_mismatch");
133
+ return explicit;
134
+ }
135
+ if (!io.isTTY()) fail("confirmation_required");
136
+ let answer: string;
137
+ try { answer = await io.answer("Compare both fingerprints out of band. Proceed with this member? [y/N] "); }
138
+ catch { return fail("not_confirmed"); }
139
+ if (answer.trim().toLowerCase() !== "y") fail("not_confirmed");
140
+ return member;
141
+ }
142
+
143
+ type DeliveryState = "pending" | "dispatching" | "accepted" | "completed" | "completed (admitted)" | "completed (inbox)" | "failed_safe" | "needs_review" | "disposed" | "disposed (accept, by person)" | "disposed (discard, by person)";
144
+ interface DeliveryRow { direction: "inbound" | "outbound"; id: string; application: string; peer: string; state: DeliveryState; disposition?: "accept" | "completed" | "retry" | "discard"; lateReceipt?: "completed (admitted)" | "completed (inbox)" | "failed_safe"; refusalReason?: RoomReceiptReason }
145
+ interface DeliverySummary { direction: "inbound"; pending: number; needsReview: number; truncated: boolean }
146
+ interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline" }
147
+ interface RoomRow { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
148
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
149
+ const REMOTE_ID = /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/;
150
+ const REFUSAL_REASONS: readonly RoomReceiptReason[] = ["admitted", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable"];
151
+ const DELIVERY_STATES: readonly string[] = ["pending", "dispatching", "accepted", "completed", "completed (admitted)", "completed (inbox)", "failed_safe", "needs_review", "disposed", "disposed (accept, by person)", "disposed (discard, by person)"];
152
+ function digestId(value: unknown): value is string {
153
+ if (typeof value !== "string" || value.length !== 43) return false;
154
+ try { decodeBase64url(value, 32); return true; } catch { return false; }
155
+ }
156
+ function deliveryRows(value: unknown): (DeliveryRow | DeliverySummary)[] {
157
+ if (!Array.isArray(value) || value.length > 401) return fail("invalid_reply");
158
+ return value.map<DeliveryRow | DeliverySummary>(item => {
159
+ const row = record(item);
160
+ if (row.direction !== "inbound" && row.direction !== "outbound") return fail("invalid_reply");
161
+ if (row.id === undefined) {
162
+ if (row.direction !== "inbound" || typeof row.pending !== "number" || !Number.isSafeInteger(row.pending) || row.pending < 0 || row.pending > 10000
163
+ || typeof row.needsReview !== "number" || !Number.isSafeInteger(row.needsReview) || row.needsReview < 0 || row.needsReview > 10000
164
+ || typeof row.truncated !== "boolean") return fail("invalid_reply");
165
+ return { direction: "inbound", pending: row.pending, needsReview: row.needsReview, truncated: row.truncated };
166
+ }
167
+ if (typeof row.id !== "string" || !(UUID.test(row.id) || digestId(row.id)) || typeof row.application !== "string" || !UUID.test(row.application)
168
+ || typeof row.peer !== "string" || !(ID.test(row.peer) || REMOTE_ID.test(row.peer))
169
+ || typeof row.state !== "string" || !DELIVERY_STATES.includes(row.state)) return fail("invalid_reply");
170
+ if (row.disposition !== undefined && (typeof row.disposition !== "string" || (row.direction === "inbound" ? !["accept", "discard"].includes(row.disposition) : !["completed", "retry", "discard"].includes(row.disposition)))) return fail("invalid_reply");
171
+ if (row.lateReceipt !== undefined && row.lateReceipt !== "completed (admitted)" && row.lateReceipt !== "completed (inbox)" && row.lateReceipt !== "failed_safe") return fail("invalid_reply");
172
+ if (row.refusalReason !== undefined && (typeof row.refusalReason !== "string" || !REFUSAL_REASONS.includes(row.refusalReason as RoomReceiptReason))) return fail("invalid_reply");
173
+ if (row.direction === "outbound" && row.state.startsWith("disposed")) return fail("invalid_reply");
174
+ const state = row.state === "completed" ? row.refusalReason === "inbox_admitted" ? "completed (inbox)" : "completed (admitted)" : row.state === "disposed" && row.disposition !== undefined ? `disposed (${row.disposition}, by person)` as DeliveryState : row.state as DeliveryState;
175
+ return { direction: row.direction, id: row.id, application: row.application, peer: row.peer, state,
176
+ ...(row.disposition !== undefined ? { disposition: row.disposition as DeliveryRow["disposition"] } : {}), ...(row.lateReceipt !== undefined ? { lateReceipt: row.lateReceipt as DeliveryRow["lateReceipt"] } : {}), ...(row.refusalReason !== undefined ? { refusalReason: row.refusalReason as RoomReceiptReason } : {}) };
177
+ });
178
+ }
179
+ function remotePeerRows(value: unknown): RemotePeerRow[] {
180
+ if (!Array.isArray(value) || value.length > 4096) return fail("invalid_reply");
181
+ return value.map<RemotePeerRow>(item => {
182
+ const row = record(item);
183
+ if (typeof row.id !== "string" || !REMOTE_ID.test(row.id) || typeof row.state !== "string" || !["idle", "busy", "paused", "offline"].includes(row.state)) return fail("invalid_reply");
184
+ return { id: row.id, state: row.state as RemotePeerRow["state"] };
185
+ });
186
+ }
187
+ function record(value: unknown): Record<string, unknown> {
188
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fail("invalid_reply");
189
+ return value as Record<string, unknown>;
190
+ }
191
+ function success(value: unknown): Record<string, unknown> {
192
+ const row = record(value);
193
+ if (row.ok !== true) fail("refused");
194
+ return row;
195
+ }
196
+ function roomRows(value: unknown): RoomRow[] {
197
+ if (!Array.isArray(value) || value.length > 64) return fail("invalid_reply");
198
+ return value.map(item => {
199
+ const row = record(item);
200
+ if (typeof row.name !== "string" || !ID.test(row.name) || typeof row.room !== "string" || row.room.length !== 43 || !B64.test(row.room)
201
+ || typeof row.owner !== "string" || !FP.test(row.owner) || typeof row.member !== "string" || !FP.test(row.member)
202
+ || typeof row.epoch !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(row.epoch)
203
+ || typeof row.state !== "string" || !/^[a-z][a-z_-]{0,31}$/.test(row.state)) return fail("invalid_reply");
204
+ // Ignore all other fields, even from a newer daemon: read output never grows into room bodies or secrets.
205
+ return { name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch, state: row.state,
206
+ ...(row.deliveries !== undefined ? { deliveries: deliveryRows(row.deliveries) } : {}), ...(row.remotePeers !== undefined ? { remotePeers: remotePeerRows(row.remotePeers) } : {}) };
207
+ });
208
+ }
209
+
210
+ const INBOX_LIST_LIMIT = 1100, INBOX_BODY_BYTES = 65536;
211
+ function inboxMeta(value: unknown): RoomPersonInboxMeta {
212
+ const row = record(value);
213
+ if (typeof row.id !== "string" || row.id.length !== 36 || !UUID.test(row.id)
214
+ || typeof row.name !== "string" || row.name.length > 32 || ID.exec(row.name)?.[0] !== row.name
215
+ || !digestId(row.room) || typeof row.sender !== "string" || row.sender.length !== 64 || !FP.test(row.sender)
216
+ || typeof row.from !== "string" || row.from.length > 65 || REMOTE_ID.exec(row.from)?.[0] !== row.from
217
+ || typeof row.receivedAt !== "number" || !Number.isSafeInteger(row.receivedAt) || row.receivedAt < 0
218
+ || typeof row.expiresAt !== "number" || !Number.isSafeInteger(row.expiresAt) || row.expiresAt < row.receivedAt
219
+ || typeof row.bytes !== "number" || !Number.isSafeInteger(row.bytes) || row.bytes < 0 || row.bytes > INBOX_BODY_BYTES
220
+ || (row.state !== "unread" && row.state !== "done" && row.state !== "discarded" && row.state !== "expired")) fail("invalid_reply");
221
+ // Deliberately never read body, title, debug or other fields on metadata paths.
222
+ return { id: row.id, name: row.name, room: row.room, sender: row.sender, from: row.from, receivedAt: row.receivedAt,
223
+ expiresAt: row.expiresAt, bytes: row.bytes, state: row.state };
224
+ }
225
+ function inboxItem(value: unknown, expectedId: string): RoomPersonInboxItem {
226
+ const meta = inboxMeta(value);
227
+ if (meta.id !== expectedId) fail("invalid_reply");
228
+ if (meta.state !== "unread") return meta; // Purged stubs never render an injected body.
229
+ const body = record(value).body;
230
+ if (typeof body !== "string" || body.length > INBOX_BODY_BYTES) fail("invalid_reply");
231
+ const bytes = new TextEncoder().encode(body).byteLength;
232
+ if (bytes > INBOX_BODY_BYTES || bytes !== meta.bytes) fail("invalid_reply");
233
+ return { ...meta, body };
234
+ }
235
+
236
+ async function runInboxCommand(options: Extract<RoomCliCommand, { op: "inbox" }>, request: RoomRequest): Promise<void> {
237
+ if (options.action === "list") {
238
+ const reply = record(await request({ t: "room", op: "inbox_list", input: options.input }));
239
+ if (reply.ok !== true && reply.code === "inbox_list_limit") fail("inbox_list_limit");
240
+ const value = success(reply).inbox;
241
+ if (!Array.isArray(value)) fail("invalid_reply");
242
+ if (value.length > INBOX_LIST_LIMIT) fail("inbox_list_limit");
243
+ const rows = value.map(inboxMeta);
244
+ if (new Set(rows.map(row => row.id)).size !== rows.length || options.input.name !== undefined && rows.some(row => row.name !== options.input.name)) fail("invalid_reply");
245
+ if (options.json) return console.log(JSON.stringify(rows));
246
+ console.log(`Room inbox (${rows.length})`);
247
+ for (const row of rows) console.log(`${row.id} ${row.name} ${row.from} ${row.state}; received ${row.receivedAt}; expires ${row.expiresAt}; ${row.bytes} bytes\n sender ${row.sender}`);
248
+ return;
249
+ }
250
+ const reply = success(await request({ t: "room", op: `inbox_${options.action}`, input: options.input }));
251
+ if (options.action !== "show") {
252
+ const item = inboxMeta(reply.item);
253
+ if (item.id !== options.input.id || item.state === "unread") fail("invalid_reply");
254
+ console.log(`Room inbox ${item.id}: ${item.state}`);
255
+ return;
256
+ }
257
+ const item = inboxItem(reply.item, options.input.id);
258
+ if (item.body === undefined) { console.log(`Room inbox ${item.id}: ${item.state} (body unavailable)`); return; }
259
+ const env: Envelope = { id: item.id, trace: item.id, hop: 0, from: item.from, to: ["user"], kind: "chat", priority: "status", ts: item.receivedAt, body: terminalText(item.body) };
260
+ console.log(frame(env));
261
+ }
262
+
263
+ /** Uses one already authenticated control connection so an ephemeral preview ticket cannot cross callers. */
264
+ export async function runRoomCommand(args: readonly string[], request: RoomRequest, io: RoomConfirmationIO = confirmationIO): Promise<void> {
265
+ const command = parseRoomCommand(args);
266
+ if (command.op === "inbox") return runInboxCommand(command, request);
267
+ if (command.op !== "mutation") {
268
+ const result = success(await request({ t: "room", op: command.op, input: command.input })), rows = roomRows(result.rooms);
269
+ if (args.includes("--json")) return console.log(JSON.stringify(rows));
270
+ console.log(`Rooms (${rows.length})`);
271
+ for (const row of rows) {
272
+ console.log(`${row.name} ${row.room} epoch ${row.epoch} ${row.state}\n owner ${row.owner}\n member ${row.member}`);
273
+ for (const peer of row.remotePeers ?? []) console.log(` peer ${peer.id}: ${peer.state}`);
274
+ for (const delivery of row.deliveries ?? []) {
275
+ if (!("id" in delivery)) console.log(` inbound: pending ${delivery.pending}, needs_review ${delivery.needsReview}${delivery.truncated ? "; truncated" : ""}`);
276
+ else console.log(` ${delivery.direction} ${delivery.id}: ${delivery.state}; application ${delivery.application}; peer ${delivery.peer}${delivery.disposition ? `; person disposition ${delivery.disposition}` : ""}${delivery.lateReceipt ? `; late receipt evidence ${delivery.lateReceipt}` : ""}${delivery.refusalReason ? `; refusal ${roomReceiptReasonText(delivery.refusalReason)}` : ""}`);
277
+ }
278
+ }
279
+ return;
280
+ }
281
+ const preview = success(await request({ t: "room", op: "preview", action: command.action, input: command.input }));
282
+ if (typeof preview.owner !== "string" || !FP.test(preview.owner) || typeof preview.member !== "string" || !FP.test(preview.member)
283
+ || typeof preview.ticket !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(preview.ticket)) fail("invalid_reply");
284
+ console.log(`Owner fingerprint: ${preview.owner}`);
285
+ console.log(`Member fingerprint: ${preview.member}`);
286
+ const confirm = await confirmMember(preview.member, command.confirm, io);
287
+ const result = success(await request({ t: "room", op: "execute", ticket: preview.ticket, confirm }));
288
+ if (result.name !== undefined) {
289
+ if (typeof result.name !== "string" || !ID.test(result.name)) fail("invalid_reply");
290
+ console.log(`Room: ${result.name}`);
291
+ }
292
+ if (result.artifact !== undefined) {
293
+ if (typeof result.artifact !== "string") fail("invalid_reply");
294
+ let encoded: string;
295
+ try { encoded = artifact(result.artifact); } catch { return fail("invalid_reply"); }
296
+ console.log("Room artifact (share out of band):");
297
+ console.log(encoded);
298
+ } else console.log(command.action === "resolve" && result.kind === "disposed" ? "room resolve: disposed" : `room ${command.action}: complete`);
299
+ }
300
+
301
+ /** Never echo tokens, broker URLs, daemon payloads or native diagnostics on refusal. */
302
+ export function roomCommandError(error: unknown): string {
303
+ return error instanceof RoomCliError ? error.message : "room: operation failed";
304
+ }
@@ -0,0 +1,16 @@
1
+ import { parseRemotePeerId } from "../rooms/application.ts";
2
+
3
+ /** Leading @tokens are addresses; malformed remote addresses must never become a broadcast body. */
4
+ export function sayRecipients(args: readonly string[]): { to: string[]; text: string[] } {
5
+ const to: string[] = [];
6
+ let count = 0;
7
+ for (const arg of args) {
8
+ if (/^@[a-z][a-z0-9-]*$/.test(arg)) { to.push(arg.slice(1)); count++; continue; }
9
+ if (arg.startsWith("@") && arg.slice(1).includes("@")) {
10
+ if (!parseRemotePeerId(arg.slice(1))) throw new Error("invalid remote peer address; expected @peer@label");
11
+ to.push(arg.slice(1)); count++; continue;
12
+ }
13
+ break;
14
+ }
15
+ return { to, text: args.slice(count) };
16
+ }
@@ -0,0 +1,49 @@
1
+ // Human output for `ahub schedule list|run|pause|resume` (#335). Pure: parsed hub JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
2
+ // Titles and details are task text and never appear here: a schedule is named by its id, period, class and queue.
3
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
4
+ import { moment } from "./sdlc-output.ts";
5
+ import type { Span, Tone } from "./console-state.ts";
6
+
7
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
8
+ const TONE: Record<string, Tone | undefined> = { due: "attention", waiting: "attention", held: "attention", paused: "muted", scheduled: undefined };
9
+ const WHEN: Record<string, string> = { scheduled: "next", due: "due now", waiting: "due, waiting for its task to close", held: "due, held (the hub is stopping or recovering)", paused: "paused" };
10
+
11
+ /** The next run, as the person reads it: a paused schedule says so and what it would have been. */
12
+ export const nextText = (row: any, now: number): string =>
13
+ row?.state === "paused" ? `paused (would be ${moment(row.next, now)})` : row?.state === "scheduled" ? moment(row.next, now) : WHEN[row?.state] ?? "unknown";
14
+
15
+ /** `ahub schedule list`: one row per configured schedule with its next run. */
16
+ export function renderScheduleList(rows: unknown, columns?: number, now = Date.now()): Span[][] {
17
+ const schedules = list(rows);
18
+ if (!schedules.length) return [[s("no schedules configured; add them under `schedules` in .agenthub/config.local.json", "muted")]];
19
+ const out: Span[][] = [...heading(`Schedules (${schedules.length})`, columns)];
20
+ out.push(...grid(["ID", "EVERY", "CLASS", "QUEUE", "STATE", "NEXT RUN", "LAST RUN", "TASK"], schedules.map((row: any) => [
21
+ s(row.id, "issueRef"), s(row.every), s(row.class), s(row.queue), s(row.state, TONE[row.state]), s(nextText(row, now), TONE[row.state]),
22
+ s(row.last ? moment(row.last, now) : "never", "muted"), s(row.task === undefined ? "-" : `#${row.task}`, "taskRef"),
23
+ ]), columns));
24
+ for (const row of schedules) {
25
+ if (row.coalesced) out.push(blank, ...pair(row.id, `${row.coalesced} due tick${row.coalesced === 1 ? "" : "s"} skipped; one run follows when it can`, columns, 12, "muted"));
26
+ if (row.error) out.push(blank, ...pair(row.id, `last run failed: ${row.error}`, columns, 12, "attention"));
27
+ }
28
+ return out;
29
+ }
30
+
31
+ const RESULT: Record<string, string> = { changed: "not run: it was paused or resumed meanwhile", ran: "ran", held: "not run: the hub is held (stopping or recovering)", waiting: "not run: the task its last run made is still open", failed: "failed" };
32
+
33
+ /** `ahub schedule run <id>`: what the run did. */
34
+ export function renderScheduleRun(result: unknown, columns?: number): Span[][] {
35
+ const r = result as any;
36
+ const out: Span[][] = [...heading(`Schedule ${r?.id ?? "?"}: ${RESULT[r?.outcome] ?? "unknown outcome"}`, columns)];
37
+ if (r?.task !== undefined) out.push(...pair("task", `#${r.task}`, columns, 8, "taskRef"));
38
+ if (r?.reason) out.push(...pair("because", String(r.reason), columns, 8, "attention"));
39
+ if (r?.outcome === "ran") out.push(...pair("next", "its period restarts now", columns, 8, "muted"));
40
+ return out;
41
+ }
42
+
43
+ /** `ahub schedule pause|resume <id>`: the schedule as the hub now holds it. */
44
+ export function renderScheduleChange(op: "pause" | "resume", rows: unknown, columns?: number, now = Date.now()): Span[][] {
45
+ const row = list(rows)[0];
46
+ const out: Span[][] = [...heading(`Schedule ${row?.id ?? "?"} ${op === "pause" ? "paused" : "resumed"}`, columns)];
47
+ if (row) out.push(...pair("state", row.state, columns, 8, TONE[row.state]), ...pair("next run", nextText(row, now), columns, 8));
48
+ return out;
49
+ }