@staix/agent-hub 0.30.0 → 0.32.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 (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
package/src/cli/room.ts CHANGED
@@ -2,7 +2,7 @@ import { decodeBase64url } from "../rooms/crypto.ts";
2
2
  import { roomReceiptReasonText, type RoomReceiptReason } from "../rooms/application.ts";
3
3
  import { frame, type Envelope } from "../hub/envelope.ts";
4
4
  import { terminalText } from "./console-state.ts";
5
- import type { RoomPersonInboxMeta, RoomPersonInboxItem } from "../rooms/store.ts";
5
+ import { ROOM_STORE_LIMITS, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "../rooms/store.ts";
6
6
  /** Membership mutations and inbox forms are person-only; main.ts refuses agent/live-test calls before importing this module. */
7
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
8
 
@@ -17,8 +17,9 @@ export type RoomRequest = (message: Record<string, unknown>) => Promise<any>;
17
17
  export interface RoomConfirmationIO { isTTY(): boolean; answer(prompt: string): Promise<string> }
18
18
 
19
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" };
20
+ constructor(readonly code: "usage" | "refused" | "invalid_reply" | "inbox_list_limit" | "confirmation_required" | "not_confirmed" | "confirmation_mismatch" | "epoch_rotated_hold_unavailable") {
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
+ epoch_rotated_hold_unavailable: "epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation" };
22
23
  super(code === "usage" ? ROOM_USAGE : `room: ${message[code]}`);
23
24
  this.name = "RoomCliError";
24
25
  }
@@ -141,9 +142,9 @@ async function confirmMember(member: string, explicit: string | undefined, io: R
141
142
  }
142
143
 
143
144
  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 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; holdReason?: "epoch rotated" }
145
146
  interface DeliverySummary { direction: "inbound"; pending: number; needsReview: number; truncated: boolean }
146
- interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline" }
147
+ interface RemotePeerRow { id: string; state: "idle" | "busy" | "paused" | "offline"; presence?: "idle" | "busy" | "paused" | "offline" | "unknown"; liveness?: "untracked" }
147
148
  interface RoomRow { name: string; room: string; owner: string; member: string; epoch: string; state: string; deliveries?: (DeliveryRow | DeliverySummary)[]; remotePeers?: RemotePeerRow[] }
148
149
  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
150
  const REMOTE_ID = /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/;
@@ -170,10 +171,11 @@ function deliveryRows(value: unknown): (DeliveryRow | DeliverySummary)[] {
170
171
  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
172
  if (row.lateReceipt !== undefined && row.lateReceipt !== "completed (admitted)" && row.lateReceipt !== "completed (inbox)" && row.lateReceipt !== "failed_safe") return fail("invalid_reply");
172
173
  if (row.refusalReason !== undefined && (typeof row.refusalReason !== "string" || !REFUSAL_REASONS.includes(row.refusalReason as RoomReceiptReason))) return fail("invalid_reply");
174
+ if (row.holdReason !== undefined && (row.holdReason !== "epoch rotated" || row.direction !== "outbound" || row.state !== "needs_review")) return fail("invalid_reply");
173
175
  if (row.direction === "outbound" && row.state.startsWith("disposed")) return fail("invalid_reply");
174
176
  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
177
  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 } : {}) };
178
+ ...(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 } : {}), ...(row.holdReason === "epoch rotated" ? { holdReason: "epoch rotated" } : {}) };
177
179
  });
178
180
  }
179
181
  function remotePeerRows(value: unknown): RemotePeerRow[] {
@@ -181,7 +183,9 @@ function remotePeerRows(value: unknown): RemotePeerRow[] {
181
183
  return value.map<RemotePeerRow>(item => {
182
184
  const row = record(item);
183
185
  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"] };
186
+ if (row.presence !== undefined && (typeof row.presence !== "string" || !["idle", "busy", "paused", "offline", "unknown"].includes(row.presence))) return fail("invalid_reply");
187
+ if (row.liveness !== undefined && row.liveness !== "untracked") return fail("invalid_reply");
188
+ return { id: row.id, state: row.state as RemotePeerRow["state"], ...(row.presence !== undefined ? { presence: row.presence as RemotePeerRow["presence"] } : {}), ...(row.liveness === "untracked" ? { liveness: "untracked" as const } : {}) };
185
189
  });
186
190
  }
187
191
  function record(value: unknown): Record<string, unknown> {
@@ -190,7 +194,7 @@ function record(value: unknown): Record<string, unknown> {
190
194
  }
191
195
  function success(value: unknown): Record<string, unknown> {
192
196
  const row = record(value);
193
- if (row.ok !== true) fail("refused");
197
+ if (row.ok !== true) fail(row.code === "epoch_rotated_hold_unavailable" ? "epoch_rotated_hold_unavailable" : "refused");
194
198
  return row;
195
199
  }
196
200
  function roomRows(value: unknown): RoomRow[] {
@@ -270,10 +274,10 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
270
274
  console.log(`Rooms (${rows.length})`);
271
275
  for (const row of rows) {
272
276
  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}`);
277
+ for (const peer of row.remotePeers ?? []) console.log(` peer ${peer.id}: ${peer.presence ?? peer.state}${peer.liveness === "untracked" ? "; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness" : ""}`);
274
278
  for (const delivery of row.deliveries ?? []) {
275
279
  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)}` : ""}`);
280
+ 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)}` : ""}${delivery.holdReason ? "; reason epoch rotated; inspect ahub queue show before resolving" : ""}`);
277
281
  }
278
282
  }
279
283
  return;
@@ -283,6 +287,12 @@ export async function runRoomCommand(args: readonly string[], request: RoomReque
283
287
  || typeof preview.ticket !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(preview.ticket)) fail("invalid_reply");
284
288
  console.log(`Owner fingerprint: ${preview.owner}`);
285
289
  console.log(`Member fingerprint: ${preview.member}`);
290
+ if (preview.outstanding !== undefined) {
291
+ if (!Array.isArray(preview.outstanding) || preview.outstanding.length > ROOM_STORE_LIMITS.outboxPerRoom
292
+ || preview.outstanding.some(value => typeof value !== "string" || !UUID.test(value)) || new Set(preview.outstanding).size !== preview.outstanding.length) fail("invalid_reply");
293
+ console.log(`Outstanding deliveries on the old epoch (${preview.outstanding.length}):`);
294
+ for (const id of preview.outstanding) console.log(` ${id}`);
295
+ }
286
296
  const confirm = await confirmMember(preview.member, command.confirm, io);
287
297
  const result = success(await request({ t: "room", op: "execute", ticket: preview.ticket, confirm }));
288
298
  if (result.name !== undefined) {
@@ -1,4 +1,5 @@
1
1
  import type { DoctorCheck } from "./output.ts";
2
+ import { parseList, pluginState, type InstalledPlugin } from "./setup.ts";
2
3
 
3
4
  const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
4
5
  const version = (value: unknown): string | undefined => typeof value === "string" && value.length <= 64 && VERSION.exec(value)?.[0] === value ? value : undefined;
@@ -26,3 +27,52 @@ export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unkno
26
27
  } catch { unknown("plugin versus daemon", "plugin and daemon versions could not be compared"); }
27
28
  return rows;
28
29
  }
30
+
31
+ /**
32
+ * #718 (#1179): the lines `ahub upgrade` ends with when the global CLI or the shared Claude plugin is older than the daemon it upgraded, or none. `ahub upgrade` upgrades one project's daemon
33
+ * only, so the person must update the CLI and the plugin themselves; without these lines a stale CLI later fails with a message that does not say why.
34
+ *
35
+ * It reuses `runtimeVersionWarnings`, the comparison `ahub doctor` prints, so the two never disagree about what is older. The one difference is a handover: when an older CLI hands the upgrade to the
36
+ * target release's own coordinator (`AGENTHUB_UPGRADE_HANDOVER=1`, set by every release since #272), the process that prints is the target itself and cannot see the CLI that started it, but that
37
+ * CLI is older by construction (it handed over because the target was newer), so `handedOver` stands for it. An unknown plugin (no `claude`, unreadable inventory) is never a claim.
38
+ */
39
+ export function globalUpdateAfterUpgrade(target: unknown, cli: unknown, plugin: unknown, handedOver = false): string[] {
40
+ const running = version(target);
41
+ if (!running) return [];
42
+ const warned = new Set(runtimeVersionWarnings(running, handedOver ? undefined : cli, plugin).filter(row => row.level === "warn").map(row => row.name));
43
+ const cliOlder = handedOver || warned.has("CLI versus daemon"), pluginOlder = warned.has("plugin versus daemon");
44
+ if (!cliOlder && !pluginOlder) return [];
45
+ return [
46
+ "ahub upgrade does not update the global CLI or the shared Claude plugin.",
47
+ cliOlder
48
+ ? `${handedOver ? "The CLI that started this upgrade is older than the daemon this upgrade installs" : "The global CLI is older than the daemon this upgrade installs"} (${running}): run \`bun add -g @staix/agent-hub@${running}\` and then \`ahub setup\` yourself.`
49
+ : `The installed plugin is older than the daemon this upgrade installs (${running}): run \`ahub setup\` yourself.`,
50
+ ];
51
+ }
52
+
53
+ export interface GlobalUpdateInputs {
54
+ /** The daemon this upgrade installs. */
55
+ target: string;
56
+ /** This process's own version. */
57
+ cli: string;
58
+ /** True when an older CLI handed the upgrade to this release's coordinator (`AGENTHUB_UPGRADE_HANDOVER=1`). */
59
+ handedOver: boolean;
60
+ /** Where this release's shipped plugin bundle lives, for `pluginState`. */
61
+ packageRoot: string;
62
+ /** The same `claude plugin list --json` probe `ahub doctor` runs. */
63
+ probe: () => { status: number | null; stdout?: string | null };
64
+ }
65
+
66
+ /**
67
+ * #718: `globalUpdateAfterUpgrade` with the plugin version read from the probe. Advice only: a missing `claude`, a probe that throws or times out, a non-zero exit or an unparsable list reads as an
68
+ * unknown plugin, which is never a claim, and nothing here can fail an upgrade.
69
+ */
70
+ export function globalUpdateAdvice(input: GlobalUpdateInputs): string[] {
71
+ let plugin: string | undefined;
72
+ try {
73
+ const probed = input.probe();
74
+ const state = pluginState(parseList<InstalledPlugin>(probed.status === 0 ? probed.stdout ?? "" : ""), input.packageRoot);
75
+ plugin = state.state === "missing" ? undefined : state.version;
76
+ } catch { /* an unknown plugin: the CLI half below does not depend on it */ }
77
+ try { return globalUpdateAfterUpgrade(input.target, input.cli, plugin, input.handedOver); } catch { return []; }
78
+ }
@@ -1,8 +1,11 @@
1
1
  import type { ContextView } from "../hub/context-window.ts";
2
+ import { memorySyncNextStep, type MemorySyncCause } from "../hub/memory-sync-state.ts";
2
3
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
3
4
 
4
- export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; reviewerUnavailable?: string; paused?: string; servedBy?: string; requestedModel?: string }
5
+ export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; reviewObserverHoldNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; attachMode?: "channel" | "tools-only"; attachEvidence?: string; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; reviewerUnavailable?: string; paused?: string; servedBy?: string; requestedModel?: string }
5
6
  export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
7
+ export interface MemorySyncRow { state?: string; at?: number; cause?: string; declinedCount?: number; declinedIdsKnown?: false; withheldCount?: number; exportedCount?: number; placementActive?: false; nextStep?: string }
8
+ export interface ReviewInputHealthRow { state?: string; cause?: string | null }
6
9
 
7
10
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
8
11
  export function backendLabel(backend: BackendRow): string {
@@ -15,3 +18,40 @@ export function backendLabel(backend: BackendRow): string {
15
18
  export function contextLine(reading: ContextView, time = (at: number) => new Date(at).toISOString()): string {
16
19
  return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${time(reading.measuredAt)}` : ""})`;
17
20
  }
21
+
22
+ /** Human status copy for machine-local sync; upstream error text never enters this projection. */
23
+ export function memorySyncLine(sync: MemorySyncRow, time = (at: number) => new Date(at).toISOString()): string {
24
+ const allowedStates = ["disabled", "unavailable", "notrun", "not-configured", "stopped", "ok", "failed", "unknown"];
25
+ const state = allowedStates.includes(sync.state ?? "") ? sync.state! : "unknown";
26
+ const hasTimestamp = sync.at !== undefined;
27
+ const validTimestamp = Number.isSafeInteger(sync.at) && Math.abs(sync.at!) <= 8_640_000_000_000_000;
28
+ const stamp = hasTimestamp ? `; automatic, last ${validTimestamp ? time(sync.at!) : "unknown"}` : "";
29
+ if (state === "not-configured" || state === "stopped") return `${state}${stamp}; next: ${memorySyncNextStep(state === "stopped" ? "stopped" : "remote")}`;
30
+ if (state === "ok" && Number.isSafeInteger(sync.withheldCount) && sync.withheldCount! >= 0
31
+ && Number.isSafeInteger(sync.exportedCount) && sync.exportedCount! >= 0)
32
+ return `ok${stamp}; ${sync.withheldCount} row${sync.withheldCount === 1 ? "" : "s"} withheld locally; ${sync.exportedCount} exported`;
33
+ if (state !== "failed") return `${state}${stamp}`;
34
+ const causes = ["declined", "remote", "binding", "stopped", "transport", "unknown"];
35
+ const cause = (causes.includes(sync.cause ?? "") ? sync.cause : "unknown") as MemorySyncCause;
36
+ if (cause === "declined" && sync.placementActive === false) {
37
+ const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? String(sync.declinedCount) : "unknown";
38
+ return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store (agent-mem has no activation command yet, agent-mem#281). Inspect with `ahub memory declined`.";
39
+ }
40
+ const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? `, ${sync.declinedCount} declined` : "";
41
+ const ids = cause === "declined" && sync.declinedIdsKnown === false ? "; declined ID set unknown" : "";
42
+ return `failed (${cause}${count})${stamp}${ids}; next: ${memorySyncNextStep(cause)}`;
43
+ }
44
+
45
+ /** Public-safe status guidance; never print journal exception text or provenance contents. */
46
+ export function reviewInputHealthLine(health: ReviewInputHealthRow): string | undefined {
47
+ if (health.state === "healthy") return undefined;
48
+ const cause = health.state === "corrupt" && ["provenance_corrupt", "provenance_incomplete"].includes(health.cause ?? "") ? health.cause!
49
+ : health.state === "corrupt" ? "provenance_corrupt"
50
+ : health.state === "incomplete" && ["provenance_incomplete", "incomplete_markers", "legacy_coverage"].includes(health.cause ?? "") ? health.cause!
51
+ : "unknown";
52
+ const next = cause === "incomplete_markers" ? "verify the peer's next ordinary input or confirmed session change"
53
+ : cause === "legacy_coverage" ? "keep affected work held until review-input coverage is confirmed"
54
+ : "keep affected work held; inspect or repair only review-input provenance, preserving delivery and task state";
55
+ const state = health.state === "incomplete" || health.state === "corrupt" ? health.state : "unknown";
56
+ return `${state} (${cause}); next: ${next}`;
57
+ }
@@ -2,6 +2,7 @@ import type { BusEvent } from "../hub/bus.ts";
2
2
 
3
3
  export function renderTailEvent(e: BusEvent): string {
4
4
  if (e.t === "state") return ` . ${e.peer} is ${e.state}`;
5
+ if (e.t === "remote_delivery_settled") return ` . remote delivery ${e.deliveryId} settled for ${e.peer} (${e.outcome})`;
5
6
  if (e.t === "undeliverable") return ` ! gave up delivering ${e.env.id} (from ${e.env.from}) to ${e.peer}`;
6
7
  if (e.t === "envelope" && e.env.from === "hub" && e.env.kind !== "chat") {
7
8
  return `${new Date(e.env.ts).toLocaleTimeString()} hub -> ${e.env.to?.join(",")} [${e.env.kind}${e.env.refs?.task ? ` #${e.env.refs.task}` : ""}]\n${e.env.body.split("\n")[0]!.replace(/^/, " ")}`;
@@ -51,6 +51,11 @@ export interface UpgradeHost {
51
51
  entry: string;
52
52
  /** This CLI's version. */
53
53
  version: string;
54
+ /**
55
+ * #718 (#1179): lines to print after the upgrade completed, under the summary: the global CLI or the shared Claude plugin is older than the upgraded daemon and `ahub upgrade` does not update either.
56
+ * Never part of a blocked, cancelled or abandoned operation, and empty when nothing is older or the versions are unknown.
57
+ */
58
+ afterCompleted?(op: RecoveryOperation): Promise<string[]> | string[];
54
59
  }
55
60
 
56
61
  function emit(io: ScreenIO, lines: readonly string[]): void { for (const line of lines) io.out(line); }
@@ -69,6 +74,13 @@ async function summary(host: UpgradeHost, io: ScreenIO, op: RecoveryOperation):
69
74
  const live = await host.endInspection?.(op).catch(() => ({} as Record<string, Inspection | undefined>));
70
75
  emit(io, renderSummary(op, live ?? {}, io.presentation));
71
76
  }
77
+ /** #718: the update step for the global CLI and plugin, once, after a completed upgrade (both callers have just seen `completed`). A host that cannot read the versions, or a failure reading them, prints nothing: it is advice, never a failure. */
78
+ async function globalUpdate(host: UpgradeHost, io: ScreenIO, op: RecoveryOperation): Promise<void> {
79
+ if (op.plan.kind !== "upgrade") return;
80
+ let lines: string[] = [];
81
+ try { lines = await host.afterCompleted?.(op) ?? []; } catch { /* advice only */ }
82
+ for (const line of lines) human(io, line);
83
+ }
72
84
  const clock = (io: ScreenIO) => io.presentation?.now() ?? Date.now();
73
85
 
74
86
  const yes = async (io: ScreenIO, question: string): Promise<boolean> => /^y(es)?$/i.test((await ask(io, question)) ?? "");
@@ -236,7 +248,7 @@ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: strin
236
248
  for (let acted = false, reset = false; ;) {
237
249
  if (io.interrupted()) return "quit";
238
250
  const op = host.read(id), runner = host.runner(id);
239
- if (op.phase === "completed") { if (io.presentation) await summary(host, io, op); else human(io, `${op.plan.kind} to ${op.plan.version} completed`); return "completed"; }
251
+ if (op.phase === "completed") { if (io.presentation) await summary(host, io, op); else human(io, `${op.plan.kind} to ${op.plan.version} completed`); await globalUpdate(host, io, op); return "completed"; }
240
252
  if (op.phase === "cancelled") {
241
253
  if (io.presentation) await summary(host, io, op);
242
254
  else human(io, `operation ${id} is ${op.disposition ? "ended (abandoned, not completed)" : "cancelled"}; the recovery lock is free`);
@@ -351,7 +363,7 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
351
363
  try { op = await host.apply(plan, () => io.interrupted()); } catch (error) { human(io, `ahub: ${(error as Error).message}`); continue; }
352
364
  human(io, `operation ${op.id} started; Enter opens its menu, Ctrl+C leaves, and neither stops the ${plan.kind}`);
353
365
  const end = await follow(host, io, op.id);
354
- if (end === "completed") { if (io.presentation) await summary(host, io, host.read(op.id)); else human(io, `${plan.kind} to ${plan.version} completed`); return; }
366
+ if (end === "completed") { if (io.presentation) await summary(host, io, host.read(op.id)); else human(io, `${plan.kind} to ${plan.version} completed`); await globalUpdate(host, io, host.read(op.id)); return; }
355
367
  if (end === "left") return;
356
368
  // Blocked, cancelled or left to its runner: the lock check above opens its screen, or plans again once it is free.
357
369
  }
@@ -171,6 +171,14 @@ function commands(text: string, nest = 0): Command[] | undefined {
171
171
  function object(value: unknown): Record<string, unknown> | undefined {
172
172
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
173
173
  }
174
+ /**
175
+ * #725 (#1193): the device files a redirect can name without writing any file: `2>/dev/null` is the common case. Matched as the exact literal the command wrote, never as a resolved path, so
176
+ * `/dev/../tmp/x`, `/dev//null`, `/dev/null/x` and `/dev/sda` stay outputs outside the root. Only `/dev/null` and `/dev/tty` are listed (the person's ruling): no descriptor stands behind either. `/dev/stdout`,
177
+ * `/dev/stderr`, `/dev/fd/1` and `/dev/fd/2` reopen whatever file fd 1 or 2 already points at, and a `>` truncates it (`sh -c 'echo x >/dev/stdout' >>/tmp/log` truncates /tmp/log on Linux), so they stay
178
+ * outputs, as does every `/dev/fd/<n>`. The redirect itself is still recorded (`redirected`), so the read-only git fast path stays closed.
179
+ */
180
+ const DEVICE_OUTPUTS: ReadonlySet<string> = new Set(["/dev/null", "/dev/tty"]);
181
+ const isDeviceOutput = (target: string): boolean => DEVICE_OUTPUTS.has(target);
174
182
  function outside(target: string, root: string, cwd: string): boolean {
175
183
  if (!isAbsolute(root) || !target || /[$`]/.test(target)) return false;
176
184
  if (target.startsWith("~") || !cwd && !isAbsolute(target)) return true;
@@ -646,7 +654,7 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
646
654
  if (nested) sequence(nested, root, cwd, found, depth + 1, bare ? floor : undefined);
647
655
  }
648
656
  }
649
- if (command.outputs.some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
657
+ if (command.outputs.some(p => !isDeviceOutput(p) && cwd.some(at => outside(p, root, at)))) found.add("destroy");
650
658
  if (program === "git") {
651
659
  const heads = commandHeads(words, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"],
652
660
  ["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);