@staix/agent-hub 0.29.0 → 0.31.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 (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -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 +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. package/src/pet/window.ts +0 -154
@@ -0,0 +1,30 @@
1
+ /**
2
+ * #1054 T4: `ahub results [--unread] [--read <id> | --read-all | --unread-last]`. Pure: argv to one closed request, never a decision about whether it applies (the hub checks
3
+ * that a result exists and is unread). At most one action flag; `--unread` only narrows the list and takes no action.
4
+ */
5
+ export const RESULT_ID = /^cmp-[a-f0-9]{12}$/;
6
+ export type ResultsRequest = { action: "list"; unread: boolean } | { action: "read"; id: string } | { action: "read-all" } | { action: "unread-last" };
7
+ export type ResultsArgs = { ok: true; value: ResultsRequest } | { ok: false; error: string };
8
+ export const RESULTS_USAGE = "usage: ahub results [--unread] [--read <id> | --read-all | --unread-last] [--json] [--color=auto|always|never]";
9
+
10
+ export function parseResultsArgs(args: readonly string[]): ResultsArgs {
11
+ const fail = (error: string): { ok: false; error: string } => ({ ok: false, error: `${RESULTS_USAGE}; ${error}` });
12
+ let unread = false;
13
+ const actions: ResultsRequest[] = [];
14
+ for (let index = 0; index < args.length; index++) {
15
+ const word = args[index]!;
16
+ if (word === "--unread") { unread = true; continue; }
17
+ if (word === "--read-all") { actions.push({ action: "read-all" }); continue; }
18
+ if (word === "--unread-last") { actions.push({ action: "unread-last" }); continue; }
19
+ if (word === "--read" || word.startsWith("--read=")) {
20
+ const id = word === "--read" ? args[++index] : word.slice("--read=".length);
21
+ if (id === undefined || !RESULT_ID.test(id)) return fail("--read needs a result id like cmp-0123456789ab (ahub results lists them)");
22
+ actions.push({ action: "read", id });
23
+ continue;
24
+ }
25
+ return fail(word.startsWith("--") ? `unknown flag ${word.split("=")[0]}` : `unexpected operand ${word.slice(0, 40)}`);
26
+ }
27
+ if (actions.length > 1) return fail("give one of --read, --read-all and --unread-last");
28
+ if (actions.length === 1) { if (unread) return fail("--unread only narrows the list; it does not combine with an action"); return { ok: true, value: actions[0]! }; }
29
+ return { ok: true, value: { action: "list", unread } };
30
+ }
@@ -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
  }
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+
5
+ /**
6
+ * #1057 D5: `ahub worktrees tidy`. This module is the ONLY place a worktree or a local branch is removed, and it runs in the person's own CLI
7
+ * process: the daemon never imports it (a test greps for that). A preview reads facts and runs read-only git; `--confirm <fingerprint>` re-reads
8
+ * everything, refuses the whole run if the row set changed, and otherwise runs exactly two argv shapes per row:
9
+ *
10
+ * git -C <root> worktree remove <path> (no --force: git itself refuses a dirty or locked tree)
11
+ * git -C <root> branch -d <branch> (never -D: git itself refuses an unmerged branch)
12
+ *
13
+ * No other removal argv exists here, `--force` and `-D` appear in no argv, and `assertRemovalArgv` checks the shape right before each call.
14
+ */
15
+
16
+ /** One settled row from the hub's `worktrees` reply, resolved against the project root by `rowFromReply`; `head` and `locked` come from git here, never from the daemon. */
17
+ export interface TidyRow { path: string; shown: string; branch: string | null; head: string; locked: boolean; reason: string; instance: boolean; taskState?: string;
18
+ /** The branch THIS process read from git's own list (`null` when detached), as opposed to `branch`, which is the daemon's word. They must agree or the row is refused. */
19
+ liveBranch?: string | null }
20
+ export type RefusalReason = "branch changed since preview; run tidy again" | "main checkout" | "instance worktree" | "dirty" | "locked" | "no branch" | "unpushed commits" | "no upstream" | "branch not merged into the default branch" | "task not approved or cancelled" | "path missing" | "outside the project" | "git could not tell";
21
+ export interface TidyEntry { path: string; shown: string; branch: string | null; reason: string; refused: RefusalReason[] }
22
+ export interface TidyPlan { entries: TidyEntry[]; removable: TidyEntry[]; fingerprint: string }
23
+
24
+ /** Read-only and removal git, bounded. `undefined` is a failed or timed-out call, never an empty answer. */
25
+ export interface TidyGit { run(cwd: string, args: string[]): { status: number | null; stdout: string } | undefined }
26
+
27
+ /** The only two removal argv shapes. A word outside them, a flag, or a path that starts with `-` refuses before git runs. */
28
+ export function removalArgv(root: string, path: string, branch: string): [string[], string[]] {
29
+ return [["-C", root, "worktree", "remove", path], ["-C", root, "branch", "-d", branch]];
30
+ }
31
+ export function assertRemovalArgv(argv: readonly string[]): void {
32
+ const [, , verb, action, target] = argv;
33
+ const worktree = argv.length === 5 && argv[0] === "-C" && verb === "worktree" && action === "remove";
34
+ const branch = argv.length === 5 && argv[0] === "-C" && verb === "branch" && action === "-d";
35
+ if (!(worktree || branch) || !target || target.startsWith("-") || argv.some(word => word === "--force" || word === "-f" || word === "-D" || word === "--delete")) throw new Error("refused: not one of the two tidy removal commands");
36
+ if (!argv[1] || argv[1].startsWith("-")) throw new Error("refused: the project root is not a path");
37
+ }
38
+
39
+ const branchOk = (branch: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(branch) && !branch.includes("..") && !branch.endsWith("/") && !branch.endsWith(".lock");
40
+ const out = (r: { status: number | null; stdout: string } | undefined): string | undefined => r && r.status === 0 ? r.stdout.trim() : undefined;
41
+ const raw = (r: { status: number | null; stdout: string } | undefined): string | undefined => r && r.status === 0 ? r.stdout : undefined;
42
+
43
+ /**
44
+ * The refusal reasons for one settled row, from read-only git. Every check that cannot be answered refuses ("git could not tell"): unknown is never safe.
45
+ * Rows the daemon already shelved as `parked` still pass through these checks: parking is the person's wish to hide a row, not proof it is safe to delete.
46
+ */
47
+ export function refusals(row: TidyRow, root: string, git: TidyGit, defaultBranch: string | undefined, exists: (path: string) => boolean = p => { try { return statSync(p).isDirectory(); } catch { return false; } }): RefusalReason[] {
48
+ const why: RefusalReason[] = [];
49
+ if (row.path === root) return ["main checkout"];
50
+ if (row.instance) return ["instance worktree"];
51
+ if (!row.path.startsWith(root + "/")) why.push("outside the project");
52
+ if (row.locked) why.push("locked");
53
+ if (!row.branch || !branchOk(row.branch)) why.push("no branch");
54
+ // The daemon named a branch and git, read here and now, says another (or none): the checks below would inspect the wrong branch and `branch -d` would target a stale name.
55
+ if (row.liveBranch !== undefined && row.liveBranch !== row.branch) why.push("branch changed since preview; run tidy again");
56
+ if (row.taskState !== undefined && row.taskState !== "approved" && row.taskState !== "cancelled") why.push("task not approved or cancelled");
57
+ if (!exists(row.path)) { why.push("path missing"); return why; }
58
+ const status = git.run(row.path, ["status", "--porcelain", "--untracked-files=all"]);
59
+ if (!status || status.status !== 0) why.push("git could not tell"); else if (status.stdout.trim() !== "") why.push("dirty");
60
+ if (row.branch && branchOk(row.branch)) {
61
+ const upstream = git.run(row.path, ["rev-parse", "--abbrev-ref", `${row.branch}@{upstream}`]);
62
+ if (!upstream) why.push("git could not tell");
63
+ else if (upstream.status !== 0) why.push("no upstream");
64
+ else {
65
+ const ahead = out(git.run(row.path, ["rev-list", "--count", `${upstream.stdout.trim()}..${row.branch}`]));
66
+ if (ahead === undefined) why.push("git could not tell"); else if (ahead !== "0") why.push("unpushed commits");
67
+ }
68
+ if (!defaultBranch) why.push("git could not tell");
69
+ else {
70
+ const merged = git.run(row.path, ["merge-base", "--is-ancestor", row.branch, defaultBranch]);
71
+ if (!merged) why.push("git could not tell"); else if (merged.status === 1) why.push("branch not merged into the default branch"); else if (merged.status !== 0) why.push("git could not tell");
72
+ }
73
+ }
74
+ return [...new Set(why)];
75
+ }
76
+
77
+ /** The repository's default branch from its origin HEAD, else `main` or `master` when exactly one exists locally. Read-only. */
78
+ export function defaultBranchOf(root: string, git: TidyGit): string | undefined {
79
+ const head = out(git.run(root, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]));
80
+ if (head && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._/-]+$/.test(head)) return head;
81
+ const local = ["main", "master"].filter(name => git.run(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`])?.status === 0);
82
+ return local.length === 1 ? local[0] : undefined;
83
+ }
84
+
85
+ /** The fingerprint covers every row's path, branch, HEAD and refusals, in path order: any change to the set or to a row changes it. */
86
+ export function fingerprintOf(entries: readonly { path: string; branch: string | null; head: string; refused: readonly string[]; liveBranch?: string | null }[]): string {
87
+ const hash = createHash("sha256");
88
+ for (const e of [...entries].sort((a, b) => a.path.localeCompare(b.path))) hash.update(JSON.stringify([e.path, e.branch, e.liveBranch ?? null, e.head, [...e.refused].sort()])).update("\n");
89
+ return hash.digest("hex").slice(0, 16);
90
+ }
91
+
92
+ /**
93
+ * The daemon shows project-relative paths and never an absolute one outside the project. A settled row whose path is outside the project (`outside: true`)
94
+ * or that names `..` cannot be resolved safely, so it becomes a refused row with no path to operate on.
95
+ */
96
+ export function rowsFromReply(settled: readonly { path?: unknown; branch?: unknown; reason?: unknown; outside?: unknown; taskState?: unknown }[], root: string, git: TidyGit): TidyRow[] {
97
+ const inventory = raw(git.run(root, ["worktree", "list", "--porcelain", "-z"]));
98
+ const listed = new Map<string, { head: string; locked: boolean; branch: string | null }>();
99
+ let current: { path: string; head: string; locked: boolean; branch: string | null } | undefined;
100
+ for (const field of (inventory ?? "").split("\0")) {
101
+ if (field === "") { if (current) listed.set(current.path, current); current = undefined; continue; }
102
+ if (field.startsWith("worktree ")) { if (current) listed.set(current.path, current); current = { path: field.slice(9), head: "", locked: false, branch: null }; }
103
+ else if (current && field.startsWith("HEAD ")) current.head = field.slice(5);
104
+ else if (current && field.startsWith("branch refs/heads/")) current.branch = field.slice(18); // `detached` has no branch field: it stays null
105
+ else if (current && (field === "locked" || field.startsWith("locked "))) current.locked = true;
106
+ }
107
+ if (current) listed.set(current.path, current);
108
+ return settled.flatMap(row => {
109
+ if (typeof row.path !== "string" || row.outside === true || row.path.includes("\0") || row.path.split("/").includes("..")) return [];
110
+ const absolute = row.path === "." ? root : resolve(join(root, row.path));
111
+ const fact = listed.get(absolute);
112
+ // Only a path git itself lists is operated on: the daemon's word alone never names a directory to remove.
113
+ if (!fact) return [];
114
+ return [{ path: absolute, shown: row.path, branch: typeof row.branch === "string" ? row.branch : null, head: fact.head, locked: fact.locked, liveBranch: fact.branch, reason: String(row.reason ?? ""), instance: false, ...(typeof row.taskState === "string" ? { taskState: row.taskState } : {}) }];
115
+ });
116
+ }
117
+
118
+ export function planTidy(rows: readonly TidyRow[], root: string, git: TidyGit): TidyPlan {
119
+ const defaultBranch = defaultBranchOf(root, git);
120
+ const detailed = rows.map(row => ({ row, refused: refusals(row, root, git, defaultBranch) }));
121
+ const entries: TidyEntry[] = detailed.map(({ row, refused }) => ({ path: row.path, shown: row.shown, branch: row.branch, reason: row.reason, refused }));
122
+ return { entries, removable: entries.filter(e => e.refused.length === 0), fingerprint: fingerprintOf(detailed.map(({ row, refused }) => ({ path: row.path, branch: row.branch, ...(row.liveBranch !== undefined ? { liveBranch: row.liveBranch } : {}), head: row.head, refused }))) };
123
+ }
124
+
125
+ export interface TidyOutcome { path: string; shown: string; branch: string; worktreeRemoved: boolean; branchDeleted: boolean; note?: string }
126
+ /**
127
+ * Re-read, compare, then remove. `fresh` is a second `planTidy` the caller took AFTER the person typed the fingerprint, so a worktree that
128
+ * changed in between refuses the whole run before anything is removed. Each removal is read back; a failed `worktree remove` skips its branch.
129
+ */
130
+ export function confirmTidy(fresh: TidyPlan, fingerprint: string, root: string, git: TidyGit): { ok: false; error: string } | { ok: true; outcomes: TidyOutcome[] } {
131
+ if (!/^[0-9a-f]{16}$/.test(fingerprint)) return { ok: false, error: "the fingerprint is 16 hex digits, from the preview line `fingerprint`" };
132
+ if (fresh.fingerprint !== fingerprint) return { ok: false, error: "the worktrees changed since the preview, so nothing was removed; run ahub worktrees tidy again and confirm the new fingerprint" };
133
+ if (!fresh.removable.length) return { ok: false, error: "nothing is safe to remove; see the refusals in the preview" };
134
+ const outcomes: TidyOutcome[] = [];
135
+ for (const entry of fresh.removable) {
136
+ const branch = entry.branch!;
137
+ const [removeWorktree, deleteBranch] = removalArgv(root, entry.path, branch);
138
+ assertRemovalArgv(removeWorktree); assertRemovalArgv(deleteBranch);
139
+ const removed = git.run(root, removeWorktree.slice(2));
140
+ const gone = removed?.status === 0 && git.run(root, ["worktree", "list", "--porcelain", "-z"])?.stdout.split("\0").every(field => field !== `worktree ${entry.path}`) === true;
141
+ if (!gone) { outcomes.push({ path: entry.path, shown: entry.shown, branch, worktreeRemoved: false, branchDeleted: false, note: "git refused or did not confirm removing the worktree; it and its branch were kept" }); continue; }
142
+ const deleted = git.run(root, deleteBranch.slice(2));
143
+ const branchGone = deleted?.status === 0 && git.run(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`])?.status === 1;
144
+ outcomes.push({ path: entry.path, shown: entry.shown, branch, worktreeRemoved: true, branchDeleted: branchGone, ...(branchGone ? {} : { note: "the worktree was removed; git kept the branch (not fully merged), which is safe" }) });
145
+ }
146
+ return { ok: true, outcomes };
147
+ }
148
+
149
+ /** Real git, bounded, run in this CLI process. Only the removal argv of `removalArgv` and the read shapes the checks above use reach it. */
150
+ export const realTidyGit = (spawn: (argv: string[], cwd: string) => { status: number | null; stdout: string } | undefined): TidyGit => ({
151
+ run(cwd, args) {
152
+ const write = args[0] === "worktree" && args[1] === "remove" || args[0] === "branch" && args[1] === "-d";
153
+ if (write) assertRemovalArgv(["-C", cwd, ...args]); // a removal always runs in the project root, with the worktree path or branch as its operand
154
+ // `--no-optional-locks`: a plain `git status` rewrites the worktree's own index file, which is exactly what the shelf reads as activity (D1). Without it a tidy
155
+ // preview would make every settled worktree look freshly edited, and it would leave the shelf on the next read. The preview must not change what it measures.
156
+ return spawn(["--no-optional-locks", "-C", cwd, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], cwd);
157
+ },
158
+ });
@@ -0,0 +1,43 @@
1
+ // Human output for `ahub worktrees` and `ahub worktrees tidy` (#1057). Pure: data in, spans out, no I/O. `--json` is the caller's branch and prints the same data.
2
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
3
+ import { moment } from "./sdlc-output.ts";
4
+ import type { Span } from "./console-state.ts";
5
+
6
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
7
+ const reasonLabel = (row: any): string => row?.reason === "merged" ? "PR merged" : row?.reason === "closed" ? "PR closed" : row?.reason === "quiet" ? "quiet" : row?.reason === "parked" ? "parked" : row?.reason === "settled" ? "settled" : "";
8
+ const taskLabel = (row: any): string => row?.private === true ? "task: private" : typeof row?.task === "number" ? `#${row.task}${typeof row.pr === "number" ? ` PR ${row.pr}` : ""}` : "";
9
+
10
+ /** `ahub worktrees`: live rows, then the shelf with each settled row's reason. */
11
+ export function renderWorktrees(reply: unknown, columns?: number, now = Date.now()): Span[][] {
12
+ const r = reply as any, live = list(r?.live), settled = list(r?.settled);
13
+ const rows = (items: any[], withReason: boolean) => items.map((row: any) => [
14
+ s(row.path), s(row.branch ?? "(detached)", "muted"), s(taskLabel(row), "issueRef"), ...(withReason ? [s(reasonLabel(row), "attention")] : []), s(moment(row.lastActiveAt, now), "muted"),
15
+ ]);
16
+ const out: Span[][] = [...heading(`Worktrees: ${live.length} live, ${settled.length} settled`, columns)];
17
+ if (live.length) out.push(...grid(["PATH", "BRANCH", "TASK", "ACTIVE"], rows(live, false), columns));
18
+ if (settled.length) out.push(blank, ...heading("Shelf (settled)", columns), ...grid(["PATH", "BRANCH", "TASK", "WHY", "ACTIVE"], rows(settled, true), columns));
19
+ if (!live.length && !settled.length) out.push([s("no worktrees", "muted")]);
20
+ if (r?.complete === false) out.push(blank, [s("the list was cut off at its bound; some worktrees are not shown", "attention")]);
21
+ if (settled.length) out.push(blank, [s("preview what could be removed with ahub worktrees tidy; nothing is removed until you confirm", "muted")]);
22
+ return out;
23
+ }
24
+
25
+ /** `ahub worktrees tidy`: each settled row that is safe to remove, each refused row with its reasons, and the fingerprint to confirm. */
26
+ export function renderTidyPreview(plan: unknown, columns?: number): Span[][] {
27
+ const p = plan as any, entries = list(p?.entries), removable = list(p?.removable), refused = entries.filter((e: any) => list(e.refused).length);
28
+ const out: Span[][] = [...heading(`Tidy preview: ${removable.length} safe to remove, ${refused.length} refused`, columns)];
29
+ if (removable.length) out.push(...grid(["PATH", "BRANCH", "WHY SETTLED"], removable.map((e: any) => [s(e.shown), s(e.branch ?? ""), s(reasonLabel(e))]), columns));
30
+ if (refused.length) out.push(blank, ...heading("Refused", columns), ...grid(["PATH", "BRANCH", "REFUSED BECAUSE"], refused.map((e: any) => [s(e.shown), s(e.branch ?? ""), s(list(e.refused).join("; "), "attention")]), columns));
31
+ if (!entries.length) out.push([s("nothing is on the shelf", "muted")]);
32
+ if (removable.length) out.push(blank, ...pair("fingerprint", String(p.fingerprint), columns, 12), [s(`confirm with: ahub worktrees tidy --confirm ${p.fingerprint}`, "muted")], [s("each removal runs git worktree remove and git branch -d, without force; git keeps anything it considers unsafe", "muted")]);
33
+ return out;
34
+ }
35
+
36
+ /** `ahub worktrees tidy --confirm`: what happened to each row. */
37
+ export function renderTidyResult(outcomes: unknown, columns?: number): Span[][] {
38
+ const rows = list(outcomes);
39
+ const out: Span[][] = [...heading(`Tidy: ${rows.filter((o: any) => o.worktreeRemoved).length} of ${rows.length} removed`, columns)];
40
+ out.push(...grid(["PATH", "BRANCH", "WORKTREE", "BRANCH"], rows.map((o: any) => [s(o.shown), s(o.branch), s(o.worktreeRemoved ? "removed" : "kept", o.worktreeRemoved ? "muted" : "attention"), s(o.branchDeleted ? "deleted" : "kept", o.branchDeleted ? "muted" : "attention")]), columns));
41
+ for (const o of rows.filter((x: any) => x.note)) out.push([s(`${o.shown}: ${o.note}`, "muted")]);
42
+ return out;
43
+ }
@@ -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"]);