harnery 0.41.0 → 0.43.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 (269) hide show
  1. package/dist/commander.d.ts +16 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +21 -0
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +63 -5
  6. package/dist/commands/artifacts.d.ts.map +1 -1
  7. package/dist/commands/artifacts.js +113 -12
  8. package/dist/commands/browse.d.ts +164 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +263 -51
  11. package/dist/commands/claude-desktop.d.ts.map +1 -1
  12. package/dist/commands/claude-desktop.js +52 -0
  13. package/dist/commands/disk.d.ts +6 -0
  14. package/dist/commands/disk.d.ts.map +1 -0
  15. package/dist/commands/disk.js +80 -0
  16. package/dist/commands/doctor.d.ts +8 -0
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +48 -1
  19. package/dist/commands/files.d.ts +16 -0
  20. package/dist/commands/files.d.ts.map +1 -1
  21. package/dist/commands/files.js +54 -2
  22. package/dist/commands/init.d.ts +2 -2
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +43 -9
  25. package/dist/commands/rm.d.ts +4 -0
  26. package/dist/commands/rm.d.ts.map +1 -0
  27. package/dist/commands/rm.js +87 -0
  28. package/dist/commands/servers.d.ts +7 -0
  29. package/dist/commands/servers.d.ts.map +1 -0
  30. package/dist/commands/servers.js +394 -0
  31. package/dist/commands/tunnel.d.ts +65 -0
  32. package/dist/commands/tunnel.d.ts.map +1 -1
  33. package/dist/commands/tunnel.js +372 -103
  34. package/dist/commands/web-fetch.d.ts +3 -2
  35. package/dist/commands/web-fetch.d.ts.map +1 -1
  36. package/dist/commands/web-fetch.js +25 -19
  37. package/dist/commands/web.d.ts +7 -1
  38. package/dist/commands/web.d.ts.map +1 -1
  39. package/dist/commands/web.js +9 -3
  40. package/dist/core/adapters/profiles.d.ts +9 -0
  41. package/dist/core/adapters/profiles.d.ts.map +1 -1
  42. package/dist/core/adapters/profiles.js +22 -1
  43. package/dist/core/adapters/types.d.ts +9 -0
  44. package/dist/core/adapters/types.d.ts.map +1 -1
  45. package/dist/core/agents/cli.js +13 -6
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +11 -5
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +59 -13
  50. package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
  51. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  52. package/dist/core/agents/state/live-coordination-view.js +60 -0
  53. package/dist/core/agents/state/names.d.ts +43 -3
  54. package/dist/core/agents/state/names.d.ts.map +1 -1
  55. package/dist/core/agents/state/names.js +87 -3
  56. package/dist/core/artifacts/delivery-card.js +9 -2
  57. package/dist/core/artifacts/index.d.ts +140 -4
  58. package/dist/core/artifacts/index.d.ts.map +1 -1
  59. package/dist/core/artifacts/index.js +522 -49
  60. package/dist/core/config.d.ts +68 -11
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +128 -3
  63. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  64. package/dist/core/diagnostics/bundle.js +2 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  66. package/dist/core/events/v3/producers/recorder.js +16 -0
  67. package/dist/core/events/v3/projection.js +9 -1
  68. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  69. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  70. package/dist/core/hooks/adapter/events.d.ts +17 -0
  71. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  72. package/dist/core/hooks/adapter/events.js +5 -0
  73. package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
  74. package/dist/core/hooks/adapter/wiring.d.ts +37 -7
  75. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  76. package/dist/core/hooks/adapter/wiring.js +62 -4
  77. package/dist/core/hooks/cli.js +131 -23
  78. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  79. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  80. package/dist/core/hooks/fork-lineage.js +62 -0
  81. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  82. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  83. package/dist/core/hooks/resolve/transcript.js +84 -0
  84. package/dist/core/hooks/run-markers.d.ts +37 -0
  85. package/dist/core/hooks/run-markers.d.ts.map +1 -0
  86. package/dist/core/hooks/run-markers.js +109 -0
  87. package/dist/core/hooks/shell-rm.d.ts +2 -0
  88. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  89. package/dist/core/hooks/shell-rm.js +105 -0
  90. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  91. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  92. package/dist/core/hooks/shell-tokens.js +97 -0
  93. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  94. package/dist/core/hooks/shell-waiter.js +3 -93
  95. package/dist/core/semantic/contract.d.ts +5 -5
  96. package/dist/core/servers/index.d.ts +211 -0
  97. package/dist/core/servers/index.d.ts.map +1 -0
  98. package/dist/core/servers/index.js +560 -0
  99. package/dist/core/servers/net.d.ts +58 -0
  100. package/dist/core/servers/net.d.ts.map +1 -0
  101. package/dist/core/servers/net.js +270 -0
  102. package/dist/core/servers/tunnels.d.ts +14 -0
  103. package/dist/core/servers/tunnels.d.ts.map +1 -0
  104. package/dist/core/servers/tunnels.js +33 -0
  105. package/dist/core/storage/builtins.js +1 -0
  106. package/dist/core/storage/process-log.d.ts +12 -0
  107. package/dist/core/storage/process-log.d.ts.map +1 -1
  108. package/dist/core/storage/process-log.js +10 -2
  109. package/dist/core/workflow/engine.d.ts.map +1 -1
  110. package/dist/core/workflow/engine.js +30 -1
  111. package/dist/core/workflow/sandbox-projection.d.ts +13 -2
  112. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
  113. package/dist/core/workflow/sandbox-projection.js +23 -0
  114. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  115. package/dist/core/workflow/spawn-claude.js +6 -1
  116. package/dist/core/workflow/spawn-codex.d.ts +2 -0
  117. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  118. package/dist/core/workflow/spawn-codex.js +20 -1
  119. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  120. package/dist/core/workflow/spawn-cursor.js +6 -1
  121. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  122. package/dist/core/workflow/spawn-opencode.js +4 -1
  123. package/dist/core/workflow/types.d.ts +47 -2
  124. package/dist/core/workflow/types.d.ts.map +1 -1
  125. package/dist/core/workflow/worker-access.d.ts +92 -0
  126. package/dist/core/workflow/worker-access.d.ts.map +1 -0
  127. package/dist/core/workflow/worker-access.js +450 -0
  128. package/dist/lib/browser/batch-steps.d.ts +18 -0
  129. package/dist/lib/browser/batch-steps.d.ts.map +1 -0
  130. package/dist/lib/browser/batch-steps.js +82 -0
  131. package/dist/lib/browser/collect/collector.d.ts +99 -0
  132. package/dist/lib/browser/collect/collector.d.ts.map +1 -0
  133. package/dist/lib/browser/collect/collector.js +202 -0
  134. package/dist/lib/browser/collect/fields.d.ts +31 -0
  135. package/dist/lib/browser/collect/fields.d.ts.map +1 -0
  136. package/dist/lib/browser/collect/fields.js +75 -0
  137. package/dist/lib/browser/collect/index.d.ts +6 -0
  138. package/dist/lib/browser/collect/index.d.ts.map +1 -0
  139. package/dist/lib/browser/collect/index.js +5 -0
  140. package/dist/lib/browser/collect/page-driver.d.ts +14 -0
  141. package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
  142. package/dist/lib/browser/collect/page-driver.js +306 -0
  143. package/dist/lib/browser/collect/presets.d.ts +52 -0
  144. package/dist/lib/browser/collect/presets.d.ts.map +1 -0
  145. package/dist/lib/browser/collect/presets.js +176 -0
  146. package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
  147. package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
  148. package/dist/lib/browser/collect/scroll-plan.js +118 -0
  149. package/dist/lib/browser/index.d.ts +1 -1
  150. package/dist/lib/browser/index.d.ts.map +1 -1
  151. package/dist/lib/browser/page-review-judge.d.ts +2 -0
  152. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  153. package/dist/lib/browser/page-review-judge.js +13 -0
  154. package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
  155. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  156. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  157. package/dist/lib/browser/qa-run.js +1 -0
  158. package/dist/lib/browser/windows-chrome.d.ts +59 -0
  159. package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
  160. package/dist/lib/browser/windows-chrome.js +179 -0
  161. package/dist/lib/claude-desktop-share.d.ts +136 -0
  162. package/dist/lib/claude-desktop-share.d.ts.map +1 -0
  163. package/dist/lib/claude-desktop-share.js +394 -0
  164. package/dist/lib/disk-usage.d.ts +50 -0
  165. package/dist/lib/disk-usage.d.ts.map +1 -0
  166. package/dist/lib/disk-usage.js +303 -0
  167. package/dist/lib/docs-lint.d.ts +6 -0
  168. package/dist/lib/docs-lint.d.ts.map +1 -1
  169. package/dist/lib/docs-lint.js +8 -3
  170. package/dist/lib/guarded-remove.d.ts +34 -0
  171. package/dist/lib/guarded-remove.d.ts.map +1 -0
  172. package/dist/lib/guarded-remove.js +310 -0
  173. package/dist/lib/instructions/templates.js +8 -8
  174. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  175. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  176. package/dist/lib/tunnel/allowlist.js +204 -0
  177. package/dist/lib/tunnel/current-address.d.ts +41 -0
  178. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  179. package/dist/lib/tunnel/current-address.js +123 -0
  180. package/dist/lib/tunnel/error-page.d.ts +1 -1
  181. package/dist/lib/tunnel/error-page.d.ts.map +1 -1
  182. package/dist/lib/tunnel/error-page.js +19 -8
  183. package/dist/lib/tunnel/gate.js +33 -7
  184. package/dist/lib/tunnel/path-scope.d.ts +22 -0
  185. package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
  186. package/dist/lib/tunnel/path-scope.js +62 -0
  187. package/dist/lib/tunnel/processes.d.ts +39 -0
  188. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  189. package/dist/lib/tunnel/processes.js +121 -0
  190. package/dist/lib/tunnel/state.d.ts +33 -3
  191. package/dist/lib/tunnel/state.d.ts.map +1 -1
  192. package/dist/lib/tunnel/state.js +115 -30
  193. package/package.json +7 -1
  194. package/schemas/config.schema.json +91 -0
  195. package/src/commander.ts +51 -0
  196. package/src/commands/agents.ts +111 -11
  197. package/src/commands/artifacts.ts +171 -19
  198. package/src/commands/browse.ts +400 -57
  199. package/src/commands/claude-desktop.ts +61 -0
  200. package/src/commands/disk.ts +114 -0
  201. package/src/commands/doctor.ts +52 -1
  202. package/src/commands/files.ts +93 -2
  203. package/src/commands/init.ts +55 -5
  204. package/src/commands/rm.ts +115 -0
  205. package/src/commands/servers.ts +506 -0
  206. package/src/commands/tunnel.ts +494 -116
  207. package/src/commands/web-fetch.ts +30 -19
  208. package/src/commands/web.ts +9 -3
  209. package/src/core/adapters/profiles.ts +22 -1
  210. package/src/core/adapters/types.ts +10 -0
  211. package/src/core/agents/cli.ts +14 -6
  212. package/src/core/agents/render/prompt-context.ts +25 -8
  213. package/src/core/agents/render/session-context.ts +84 -16
  214. package/src/core/agents/state/live-coordination-view.ts +73 -0
  215. package/src/core/agents/state/names.ts +117 -6
  216. package/src/core/artifacts/delivery-card.ts +10 -2
  217. package/src/core/artifacts/index.ts +697 -44
  218. package/src/core/config.ts +183 -4
  219. package/src/core/diagnostics/bundle.ts +2 -1
  220. package/src/core/events/v3/producers/recorder.ts +16 -0
  221. package/src/core/events/v3/projection.ts +11 -1
  222. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  223. package/src/core/hooks/adapter/events.ts +19 -0
  224. package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
  225. package/src/core/hooks/adapter/wiring.ts +116 -8
  226. package/src/core/hooks/cli.ts +152 -22
  227. package/src/core/hooks/fork-lineage.ts +82 -0
  228. package/src/core/hooks/resolve/transcript.ts +88 -0
  229. package/src/core/hooks/run-markers.ts +132 -0
  230. package/src/core/hooks/shell-rm.ts +103 -0
  231. package/src/core/hooks/shell-tokens.ts +96 -0
  232. package/src/core/hooks/shell-waiter.ts +4 -91
  233. package/src/core/servers/index.ts +712 -0
  234. package/src/core/servers/net.ts +283 -0
  235. package/src/core/servers/tunnels.ts +39 -0
  236. package/src/core/storage/builtins.ts +1 -0
  237. package/src/core/storage/process-log.ts +17 -2
  238. package/src/core/workflow/engine.ts +38 -1
  239. package/src/core/workflow/sandbox-projection.ts +38 -2
  240. package/src/core/workflow/spawn-claude.ts +5 -1
  241. package/src/core/workflow/spawn-codex.ts +18 -1
  242. package/src/core/workflow/spawn-cursor.ts +5 -1
  243. package/src/core/workflow/spawn-opencode.ts +4 -1
  244. package/src/core/workflow/types.ts +62 -2
  245. package/src/core/workflow/worker-access.ts +525 -0
  246. package/src/lib/browser/batch-steps.ts +79 -0
  247. package/src/lib/browser/collect/collector.ts +306 -0
  248. package/src/lib/browser/collect/fields.ts +87 -0
  249. package/src/lib/browser/collect/index.ts +47 -0
  250. package/src/lib/browser/collect/page-driver.ts +327 -0
  251. package/src/lib/browser/collect/presets.ts +237 -0
  252. package/src/lib/browser/collect/scroll-plan.ts +179 -0
  253. package/src/lib/browser/index.ts +1 -0
  254. package/src/lib/browser/page-review-judge.ts +16 -0
  255. package/src/lib/browser/qa-run-contracts.ts +11 -0
  256. package/src/lib/browser/qa-run.ts +1 -0
  257. package/src/lib/browser/windows-chrome.ts +227 -0
  258. package/src/lib/claude-desktop-share.ts +511 -0
  259. package/src/lib/disk-usage.ts +347 -0
  260. package/src/lib/docs-lint.ts +8 -3
  261. package/src/lib/guarded-remove.ts +376 -0
  262. package/src/lib/instructions/templates.ts +8 -8
  263. package/src/lib/tunnel/allowlist.ts +208 -0
  264. package/src/lib/tunnel/current-address.ts +158 -0
  265. package/src/lib/tunnel/error-page.ts +25 -9
  266. package/src/lib/tunnel/gate.ts +38 -9
  267. package/src/lib/tunnel/path-scope.ts +73 -0
  268. package/src/lib/tunnel/processes.ts +149 -0
  269. package/src/lib/tunnel/state.ts +143 -30
@@ -1,12 +1,25 @@
1
1
  // Tunnel config + state persistence + provider helpers. Commands default to
2
2
  // <cwd>/.cache/tunnel/; gitignored, so the allowlist is per-machine.
3
- import { execSync } from "node:child_process";
4
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
5
- import { resolve } from "node:path";
6
- // Tunnel state lives under <root>/.cache/tunnel/. Root defaults to cwd for the
7
- // command surface and can be supplied by callers that already resolved a repo.
8
- function cachePath(tool, filename, root = process.cwd()) {
9
- const dir = resolve(root, ".cache", tool);
3
+ import { spawnSync } from "node:child_process";
4
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { delimiter, join, resolve } from "node:path";
7
+ import { isPathAllowed } from "./path-scope.js";
8
+ /** Overrides the directory that holds tunnel state and config (a host keeps it out of `.cache/`). */
9
+ export const TUNNEL_DIR_ENV = "HARNERY_TUNNEL_DIR";
10
+ /** Explicit cloudflared binary, for a host that ships a managed copy. */
11
+ export const CLOUDFLARED_ENV = "HARNERY_CLOUDFLARED";
12
+ /**
13
+ * Where tunnel state lives: `$HARNERY_TUNNEL_DIR` when set, else
14
+ * `<root>/.cache/tunnel/`. Root defaults to cwd for the command surface and can
15
+ * be supplied by callers that already resolved a repo.
16
+ */
17
+ export function tunnelDir(root = process.cwd(), env = process.env) {
18
+ const override = env[TUNNEL_DIR_ENV]?.trim();
19
+ return override ? resolve(override) : resolve(root, ".cache", "tunnel");
20
+ }
21
+ function cachePath(filename, root = process.cwd()) {
22
+ const dir = tunnelDir(root);
10
23
  if (!existsSync(dir))
11
24
  mkdirSync(dir, { recursive: true });
12
25
  return resolve(dir, filename);
@@ -46,28 +59,41 @@ function nameFromStateFile(file) {
46
59
  const DEFAULT_CONFIG = {
47
60
  allowed_ips: [],
48
61
  };
49
- /** Normalize a parsed state blob; supply `name` for pre-multi-instance files. */
62
+ /**
63
+ * Normalize a parsed state blob; supply `name` for pre-multi-instance files.
64
+ * A state written before path scopes existed reads as an empty scope, which
65
+ * the gate treats as "refuse every path".
66
+ */
50
67
  function normalizeState(raw, fallbackName) {
51
- return { ...raw, name: raw.name ?? fallbackName, provider: raw.provider ?? "cloudflare" };
68
+ return {
69
+ ...raw,
70
+ name: raw.name ?? fallbackName,
71
+ provider: raw.provider ?? "cloudflare",
72
+ allow_paths: Array.isArray(raw.allow_paths) ? raw.allow_paths : [],
73
+ };
52
74
  }
53
75
  export function readConfig() {
54
- const p = cachePath("tunnel", CONFIG_FILE);
76
+ const p = cachePath(CONFIG_FILE);
55
77
  if (!existsSync(p)) {
56
78
  writeConfig(DEFAULT_CONFIG);
57
79
  return { ...DEFAULT_CONFIG, allowed_ips: [...DEFAULT_CONFIG.allowed_ips] };
58
80
  }
59
81
  try {
60
- return JSON.parse(readFileSync(p, "utf-8"));
82
+ const parsed = JSON.parse(readFileSync(p, "utf-8"));
83
+ return {
84
+ ...parsed,
85
+ allowed_ips: Array.isArray(parsed.allowed_ips) ? parsed.allowed_ips : [],
86
+ };
61
87
  }
62
88
  catch {
63
89
  return { ...DEFAULT_CONFIG, allowed_ips: [...DEFAULT_CONFIG.allowed_ips] };
64
90
  }
65
91
  }
66
92
  export function writeConfig(cfg) {
67
- writeFileSync(cachePath("tunnel", CONFIG_FILE), JSON.stringify(cfg, null, 2));
93
+ writeFileSync(cachePath(CONFIG_FILE), JSON.stringify(cfg, null, 2));
68
94
  }
69
95
  export function readState(name = DEFAULT_INSTANCE, root = process.cwd()) {
70
- const p = cachePath("tunnel", stateFile(name), root);
96
+ const p = cachePath(stateFile(name), root);
71
97
  if (!existsSync(p))
72
98
  return null;
73
99
  try {
@@ -78,10 +104,10 @@ export function readState(name = DEFAULT_INSTANCE, root = process.cwd()) {
78
104
  }
79
105
  }
80
106
  export function writeState(state, root = process.cwd()) {
81
- writeFileSync(cachePath("tunnel", stateFile(state.name), root), JSON.stringify(state, null, 2));
107
+ writeFileSync(cachePath(stateFile(state.name), root), JSON.stringify(state, null, 2));
82
108
  }
83
109
  export function clearState(name = DEFAULT_INSTANCE, root = process.cwd()) {
84
- const p = cachePath("tunnel", stateFile(name), root);
110
+ const p = cachePath(stateFile(name), root);
85
111
  if (existsSync(p))
86
112
  unlinkSync(p);
87
113
  }
@@ -90,7 +116,7 @@ export function clearState(name = DEFAULT_INSTANCE, root = process.cwd()) {
90
116
  * `state*.json` under `.cache/tunnel/`; tolerates missing/corrupt files.
91
117
  */
92
118
  export function listStates(root = process.cwd()) {
93
- const dir = resolve(root, ".cache", "tunnel");
119
+ const dir = tunnelDir(root);
94
120
  if (!existsSync(dir))
95
121
  return [];
96
122
  const out = [];
@@ -122,7 +148,7 @@ export function isTunnelStateLive(state, processAlive = isProcessAlive) {
122
148
  const providerPid = state.cloudflared_pid ?? state.provider_pid;
123
149
  return typeof providerPid === "number" && processAlive(providerPid);
124
150
  }
125
- function tunnelTargetPort(state) {
151
+ export function tunnelTargetPort(state) {
126
152
  const value = /^[a-z][a-z\d+.-]*:\/\//i.test(state.target)
127
153
  ? state.target
128
154
  : `http://${state.target}`;
@@ -152,26 +178,85 @@ export function findLiveTunnelForOrigin(port, vhost, root = process.cwd(), proce
152
178
  state.vhost === vhost &&
153
179
  isTunnelStateLive(state, processAlive)) ?? null);
154
180
  }
155
- /**
156
- * Ensure cloudflared is on PATH or installed at ~/.local/bin/cloudflared.
157
- * Auto-downloads on Linux; throws on other platforms with brew hint.
158
- */
159
- export function ensureCloudflared() {
181
+ /** True when the tunnel's path scope forwards every one of `paths`. */
182
+ export function tunnelServesPaths(state, paths) {
183
+ return paths.every((path) => isPathAllowed(path, state.allow_paths));
184
+ }
185
+ function isRunnableFile(path) {
160
186
  try {
161
- execSync("command -v cloudflared", { stdio: "ignore" });
162
- return "cloudflared";
187
+ if (!statSync(path).isFile())
188
+ return false;
189
+ if (process.platform === "win32")
190
+ return true;
191
+ accessSync(path, constants.X_OK);
192
+ return true;
163
193
  }
164
194
  catch {
165
- /* not on PATH; fall through */
195
+ return false;
196
+ }
197
+ }
198
+ /** First `name` on PATH (honoring `.exe` on Windows), or null. Needs no shell. */
199
+ function findOnPath(name, env = process.env) {
200
+ const names = process.platform === "win32" ? [`${name}.exe`, name] : [name];
201
+ for (const dir of (env.PATH ?? env.Path ?? "").split(delimiter)) {
202
+ if (!dir)
203
+ continue;
204
+ for (const candidate of names) {
205
+ const full = join(dir, candidate);
206
+ if (isRunnableFile(full))
207
+ return full;
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+ /**
213
+ * Resolve the cloudflared binary:
214
+ * 1. `$HARNERY_CLOUDFLARED`, then `cloudflared_path` in the tunnel config: an
215
+ * explicit choice, so a missing or non-runnable file is an error, never a
216
+ * silent fall-through to some other copy.
217
+ * 2. `cloudflared` on PATH.
218
+ * 3. `~/.local/bin/cloudflared`.
219
+ * 4. Linux x86-64 and arm64: download the latest release to (3).
220
+ * Elsewhere it throws with install guidance.
221
+ */
222
+ export function ensureCloudflared(env = process.env, configuredPath = readConfig().cloudflared_path) {
223
+ const explicit = env[CLOUDFLARED_ENV]?.trim();
224
+ const fromConfig = configuredPath?.trim();
225
+ for (const [source, path] of [
226
+ [CLOUDFLARED_ENV, explicit],
227
+ ["cloudflared_path in the tunnel config", fromConfig],
228
+ ]) {
229
+ if (!path)
230
+ continue;
231
+ if (isRunnableFile(path))
232
+ return path;
233
+ throw new Error(`cloudflared is set by ${source} to ${path}, but that file is missing or not runnable.`);
166
234
  }
167
- const local = `${process.env.HOME}/.local/bin/cloudflared`;
168
- if (existsSync(local))
235
+ const onPath = findOnPath("cloudflared", env);
236
+ if (onPath)
237
+ return onPath;
238
+ const exe = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
239
+ const local = join(homedir(), ".local", "bin", exe);
240
+ if (isRunnableFile(local))
169
241
  return local;
170
- if (process.platform !== "linux") {
171
- throw new Error("cloudflared not installed. On macOS: `brew install cloudflared`. " +
242
+ const asset = process.platform === "linux"
243
+ ? { x64: "cloudflared-linux-amd64", arm64: "cloudflared-linux-arm64" }[process.arch]
244
+ : undefined;
245
+ if (!asset) {
246
+ throw new Error("cloudflared not installed. Install it (macOS: `brew install cloudflared`; Windows: " +
247
+ "`winget install Cloudflare.cloudflared`), or point `$HARNERY_CLOUDFLARED` at a binary. " +
172
248
  "On Linux it auto-installs to ~/.local/bin/cloudflared.");
173
249
  }
174
250
  process.stderr.write("Installing cloudflared to ~/.local/bin/...\n"); // lint-ok-emission: sync setup phase before structured output; pairs with the inherited stdio of the curl below
175
- execSync(`curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o ${local} && chmod +x ${local}`, { stdio: "inherit" });
251
+ mkdirSync(join(homedir(), ".local", "bin"), { recursive: true });
252
+ const download = spawnSync("curl", [
253
+ "-fsSL",
254
+ `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
255
+ "-o",
256
+ local,
257
+ ], { stdio: "inherit" });
258
+ if (download.status !== 0)
259
+ throw new Error("Downloading cloudflared failed; install it by hand.");
260
+ chmodSync(local, 0o755);
176
261
  return local;
177
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -137,6 +137,11 @@
137
137
  "types": "./dist/core/supervisor/index.d.ts",
138
138
  "import": "./dist/core/supervisor/index.js"
139
139
  },
140
+ "./core/servers": {
141
+ "bun": "./src/core/servers/index.ts",
142
+ "types": "./dist/core/servers/index.d.ts",
143
+ "import": "./dist/core/servers/index.js"
144
+ },
140
145
  "./core/diagnostics": {
141
146
  "bun": "./src/core/diagnostics/index.ts",
142
147
  "types": "./dist/core/diagnostics/index.d.ts",
@@ -261,6 +266,7 @@
261
266
  "test:web": "cd web && bun install && bun test",
262
267
  "test:integration": "bash tests/integration/run.sh",
263
268
  "smoke": "node scripts/smoke-test.mjs",
269
+ "check:local": "node scripts/check-local.mjs",
264
270
  "docs:dev": "bun run --cwd docs dev",
265
271
  "docs:build": "bun run --cwd docs build",
266
272
  "brand:showcase": "node scripts/build-brand-showcase.mjs",
@@ -240,6 +240,53 @@
240
240
  "maximum": 3650,
241
241
  "default": 3,
242
242
  "description": "Retention for `harn artifacts create` when --days is absent. Env override: HARNERY_ARTIFACT_RETENTION_DAYS."
243
+ },
244
+ "auto_clean": {
245
+ "type": "boolean",
246
+ "default": true,
247
+ "description": "Run the throttled artifact cleanup at session start and before new artifact work. Env override: HARNERY_ARTIFACT_AUTO_CLEAN."
248
+ },
249
+ "max_bytes": {
250
+ "type": "integer",
251
+ "minimum": 67108864,
252
+ "maximum": 1099511627776,
253
+ "default": 21474836480,
254
+ "description": "Soft repository budget in bytes of disk use for unheld managed workspaces. Env override: HARNERY_ARTIFACT_MAX_BYTES."
255
+ },
256
+ "max_unit_bytes": {
257
+ "type": "integer",
258
+ "minimum": 16777216,
259
+ "maximum": 1099511627776,
260
+ "default": 1073741824,
261
+ "description": "Per-workspace ceiling in bytes of disk use; a workspace created with --big is exempt. Env override: HARNERY_ARTIFACT_MAX_UNIT_BYTES."
262
+ },
263
+ "idle_grace_hours": {
264
+ "type": "integer",
265
+ "minimum": 1,
266
+ "maximum": 8760,
267
+ "default": 24,
268
+ "description": "Hours an unexpired workspace must be idle (no file change, no owner heartbeat) before a size rule may delete it. Env override: HARNERY_ARTIFACT_IDLE_GRACE_HOURS."
269
+ },
270
+ "hold_days": {
271
+ "type": "integer",
272
+ "minimum": 1,
273
+ "maximum": 365,
274
+ "default": 14,
275
+ "description": "Days a hold protects a workspace before it lapses unless renewed with `harn artifacts hold` (same id and reason). Env override: HARNERY_ARTIFACT_HOLD_DAYS."
276
+ },
277
+ "max_held_bytes": {
278
+ "type": "integer",
279
+ "minimum": 67108864,
280
+ "maximum": 1099511627776,
281
+ "default": 21474836480,
282
+ "description": "Advisory budget in bytes of disk use for held workspaces. Exceeding it produces warnings; held work is never deleted. Env override: HARNERY_ARTIFACT_MAX_HELD_BYTES."
283
+ },
284
+ "min_free_bytes": {
285
+ "type": "integer",
286
+ "minimum": 0,
287
+ "maximum": 1125899906842624,
288
+ "default": 21474836480,
289
+ "description": "Free-disk floor for the artifact store. Below it, artifact commands warn and `artifacts create --big` refuses without --allow-low-disk. 0 disables the floor. Env override: HARNERY_ARTIFACT_MIN_FREE_BYTES."
243
290
  }
244
291
  }
245
292
  },
@@ -306,6 +353,37 @@
306
353
  "type": "boolean",
307
354
  "default": false,
308
355
  "description": "Pin `workflow run` to subscription billing: API-key vars are scrubbed from every child env. Stamped true by `harn init` when the key is absent. Env override: HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=1|0."
356
+ },
357
+ "workerFullAccess": {
358
+ "type": "object",
359
+ "additionalProperties": false,
360
+ "description": "Let qualifying workflow workers launch without their vendor sandbox (ADR 0192). A launch qualifies only when this is enabled, the launching session itself can be shown to run with full access, and the worker's exact model and effort meet a floor listed here. Anything else keeps the ordinary sandboxed projection. Project config only; a user-global file cannot grant it. HARNERY_WORKFLOW_WORKER_FULL_ACCESS=0 turns it off for one process. An invalid value disables it entirely.",
361
+ "properties": {
362
+ "enabled": { "type": "boolean", "default": false },
363
+ "floors": {
364
+ "type": "object",
365
+ "description": "Per-adapter list of models a worker may run unsandboxed on. Keys are adapter ids.",
366
+ "additionalProperties": {
367
+ "type": "array",
368
+ "items": {
369
+ "type": "object",
370
+ "additionalProperties": false,
371
+ "required": ["model"],
372
+ "properties": {
373
+ "model": {
374
+ "type": "string",
375
+ "minLength": 1,
376
+ "description": "Exact model id the worker must name. Aliases and the adapter's default model never match."
377
+ },
378
+ "minEffort": {
379
+ "type": "string",
380
+ "description": "Lowest qualifying effort on the adapter's declared effort scale. Required for an adapter with an effort scale; not allowed for one that encodes effort in the model id."
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }
386
+ }
309
387
  }
310
388
  }
311
389
  },
@@ -349,6 +427,19 @@
349
427
  "default": 65536
350
428
  }
351
429
  }
430
+ },
431
+ "codexWindowsBridge": {
432
+ "type": "object",
433
+ "additionalProperties": false,
434
+ "required": ["entryPoint"],
435
+ "description": "Route Windows-native Codex hooks through a resident WSL bridge (decision record 0197). init writes a commandWindows on each Codex hook that calls entryPoint when it is on PATH and the working directory is a WSL path, and otherwise runs the usual command. Project config only.",
436
+ "properties": {
437
+ "entryPoint": {
438
+ "type": "string",
439
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
440
+ "description": "Windows command name the bridge installs, for example codex-wsl-hook. Only a bridge that implements the decision record 0197 contract should install it."
441
+ }
442
+ }
352
443
  }
353
444
  }
354
445
  },
package/src/commander.ts CHANGED
@@ -280,6 +280,38 @@ export async function registerHarneryLogStorageCommands(
280
280
  return parent;
281
281
  }
282
282
 
283
+ export interface HarneryTunnelCommandOptions {
284
+ emit?: EmitContext;
285
+ context?: HarneryProgramContext;
286
+ }
287
+
288
+ /**
289
+ * Mount `tunnel` under `parent`, for a host whose own program does not come
290
+ * from `createHarneryProgram` (a host CLI that exposes the tunnel as one of its
291
+ * command families).
292
+ */
293
+ export async function registerHarneryTunnelCommand(
294
+ parent: Command,
295
+ options: HarneryTunnelCommandOptions = {},
296
+ ): Promise<Command> {
297
+ const { registerTunnelCommand } = await import("./commands/tunnel.ts");
298
+ registerTunnelCommand(parent, options.emit ?? defaultEmit, options.context);
299
+ return parent;
300
+ }
301
+
302
+ export type { RefreshCurrentOptions, RefreshCurrentResult } from "./commands/tunnel.ts";
303
+
304
+ /**
305
+ * `tunnel allow add --current` as a function: allow this machine's public
306
+ * addresses and optionally reload running gates, keeping their URLs.
307
+ */
308
+ export async function refreshTunnelCurrentAddress(
309
+ options: import("./commands/tunnel.ts").RefreshCurrentOptions,
310
+ ): Promise<import("./commands/tunnel.ts").RefreshCurrentResult> {
311
+ const { refreshCurrentAddress } = await import("./commands/tunnel.ts");
312
+ return refreshCurrentAddress(options);
313
+ }
314
+
283
315
  export function createHarneryProgram(opts: HarneryContextOpts = {}): Command {
284
316
  const program = new Command();
285
317
  const emit = opts.emit ?? defaultEmit;
@@ -320,6 +352,12 @@ function harneryCommandBundles({
320
352
  binName,
321
353
  }: CommandBundleContext): LazyCommandBundle[] {
322
354
  return [
355
+ lazy(
356
+ "disk [path]",
357
+ "Measure checkout disk usage, including nested repositories and ignored files",
358
+ async (program) => (await import("./commands/disk.ts")).registerDiskCommand(program, emit),
359
+ { hasOptions: true },
360
+ ),
323
361
  lazy(
324
362
  "tokens <files...>",
325
363
  "Count tokens in text/markdown files (offline, uses o200k_base as Claude proxy)",
@@ -596,6 +634,13 @@ function harneryCommandBundles({
596
634
  async (program) =>
597
635
  (await import("./commands/storage.ts")).registerStorageCommand(program, emit, context),
598
636
  ),
637
+ lazy(
638
+ "rm <paths...>",
639
+ "Preview guarded removal of untracked files; --yes applies permanent deletion",
640
+ async (program) =>
641
+ (await import("./commands/rm.ts")).registerRmCommand(program, emit, context),
642
+ { hasOptions: true },
643
+ ),
599
644
  lazy("logs", "List and query bounded Harnery log families", async (program) =>
600
645
  (await import("./commands/logs.ts")).registerLogsCommand(program, emit, context),
601
646
  ),
@@ -661,6 +706,12 @@ function harneryCommandBundles({
661
706
  async (program) =>
662
707
  (await import("./commands/tunnel.ts")).registerTunnelCommand(program, emit, context),
663
708
  ),
709
+ lazy(
710
+ "servers",
711
+ "Every local server started in this project: list, stop, clean up idle session servers, read logs",
712
+ async (program) =>
713
+ (await import("./commands/servers.ts")).registerServersCommand(program, emit, context),
714
+ ),
664
715
  lazy(
665
716
  "docs",
666
717
  "Documentation tooling: freshness report, metadata, lint, sweep, index",
@@ -78,9 +78,13 @@ import {
78
78
  readHeartbeat as readHeartbeatCache,
79
79
  } from "../core/agents/state/heartbeat-writer.ts";
80
80
  import {
81
+ describeLiveAuthorityBlockV3,
82
+ liveAuthorityBlockHintV3,
83
+ liveAuthorityBlockV3,
81
84
  readLiveCoordinationRow,
82
85
  readLiveCoordinationRows,
83
86
  } from "../core/agents/state/live-coordination-view.ts";
87
+ import { forkRelationLabels, resolveForkAncestry } from "../core/agents/state/names.ts";
84
88
  import {
85
89
  type AgentActivity,
86
90
  foldSessionState,
@@ -104,6 +108,7 @@ import {
104
108
  projectCoordinationViewV3,
105
109
  readCoordinationViewV3,
106
110
  } from "../core/events/v3/coordination-view.ts";
111
+ import { nativeInstanceIdV3 } from "../core/events/v3/live-route-observer.ts";
107
112
  import { liveInstanceIdV3 } from "../core/events/v3/live-routing.ts";
108
113
  import {
109
114
  countSummarizedSinceV3,
@@ -434,9 +439,15 @@ export function registerAgentsCommand(
434
439
  "--session-id <id>",
435
440
  "Target the heartbeat with this session_id directly, bypassing the ppid walk.",
436
441
  )
437
- .action((state: string, opts: { reason?: string; sessionId?: string }) => {
438
- runLifecycle(state, opts);
439
- });
442
+ .option(
443
+ "--force-ancestor",
444
+ "Allow a fork to change its fork ancestor's lifecycle (only on explicit operator instruction)",
445
+ )
446
+ .action(
447
+ (state: string, opts: { reason?: string; sessionId?: string; forceAncestor?: boolean }) => {
448
+ runLifecycle(state, opts);
449
+ },
450
+ );
440
451
 
441
452
  cmd
442
453
  .command("end")
@@ -450,9 +461,20 @@ export function registerAgentsCommand(
450
461
  "succeeded | failed | cancelled | timed_out | denied | interrupted | unknown",
451
462
  "succeeded",
452
463
  )
453
- .action((opts: { sessionId?: string; instanceId?: string; outcome: string }) => {
454
- runEndSession(opts);
455
- });
464
+ .option(
465
+ "--force-ancestor",
466
+ "Allow a fork to finalize its fork ancestor's session (only on explicit operator instruction)",
467
+ )
468
+ .action(
469
+ (opts: {
470
+ sessionId?: string;
471
+ instanceId?: string;
472
+ outcome: string;
473
+ forceAncestor?: boolean;
474
+ }) => {
475
+ runEndSession(opts);
476
+ },
477
+ );
456
478
 
457
479
  cmd
458
480
  .command("reconcile")
@@ -663,7 +685,40 @@ const SESSION_OUTCOMES = new Set([
663
685
  "unknown",
664
686
  ]);
665
687
 
666
- function runEndSession(opts: { sessionId?: string; instanceId?: string; outcome: string }) {
688
+ /**
689
+ * Refuse when the calling session targets one of its recorded fork ancestors.
690
+ * A fork's transcript is full of its parent's name, task, and claims, so a
691
+ * fork that "cleans up its earlier self" is usually cutting off a live parent
692
+ * (2026-10-07: a fork offered to close its running parent). Returns the
693
+ * refusal message, or null when the call may proceed.
694
+ */
695
+ function forkAncestorRefusal(
696
+ root: string,
697
+ targetIds: Array<string | undefined>,
698
+ force: boolean | undefined,
699
+ action: string,
700
+ ): string | null {
701
+ if (force) return null;
702
+ const ids = new Set(targetIds.filter((id): id is string => !!id));
703
+ if (ids.size === 0) return null;
704
+ const self = resolveOwner() ?? sessionIdentityFromEnv();
705
+ if (!self || ids.has(self)) return null;
706
+ const ancestor = resolveForkAncestry(root, self).find((a) => ids.has(a.instance_id));
707
+ if (!ancestor) return null;
708
+ const label = ancestor.name ? `agent-${ancestor.name}` : ancestor.instance_id;
709
+ return (
710
+ `${label} is this session's fork ancestor: this conversation was branched from it, so its ` +
711
+ `name, task, and files in your context are its own, and it may still be running. Refusing to ` +
712
+ `${action} it. Rerun with --force-ancestor only when the operator explicitly asked for that.`
713
+ );
714
+ }
715
+
716
+ function runEndSession(opts: {
717
+ sessionId?: string;
718
+ instanceId?: string;
719
+ outcome: string;
720
+ forceAncestor?: boolean;
721
+ }) {
667
722
  const root = monorepoRoot();
668
723
  if (!root) return failCommand("not_in_repo", "not in an agent session");
669
724
  if (!SESSION_OUTCOMES.has(opts.outcome)) {
@@ -695,6 +750,15 @@ function runEndSession(opts: { sessionId?: string; instanceId?: string; outcome:
695
750
  return failCommand("session_identity_missing", "live V3 generation disappeared");
696
751
  }
697
752
  const state = record.state;
753
+ if (opts.instanceId || opts.sessionId) {
754
+ const refusal = forkAncestorRefusal(
755
+ root,
756
+ [target, state.session_id, nativeInstanceIdV3(state.instance_id)],
757
+ opts.forceAncestor,
758
+ "finalize",
759
+ );
760
+ if (refusal) return failCommand("session_is_fork_ancestor", refusal);
761
+ }
698
762
  if (state.delegations.length > 0) {
699
763
  return failCommand(
700
764
  "session_work_open",
@@ -1486,6 +1550,10 @@ function normalizeKind(kind: string | undefined | null): string {
1486
1550
  * command refuses in turn and the caller has no way to guess the path back.
1487
1551
  */
1488
1552
  function noLiveGenerationMessage(root: string, owner: string): string {
1553
+ const block = liveAuthorityBlockV3(root, owner);
1554
+ if (block) {
1555
+ return `resolved owner=${owner}; ${describeLiveAuthorityBlockV3(block)}; ${liveAuthorityBlockHintV3(resolveBinName(root))}`;
1556
+ }
1489
1557
  return `resolved owner=${owner} but no authority-safe live V3 generation exists for it; ${lifecycleReopenHint(root)}`;
1490
1558
  }
1491
1559
 
@@ -1985,7 +2053,10 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1985
2053
  });
1986
2054
  }
1987
2055
 
1988
- function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: string }): void {
2056
+ function runLifecycle(
2057
+ rawState: string,
2058
+ opts: { reason?: string; sessionId?: string; forceAncestor?: boolean },
2059
+ ): void {
1989
2060
  const state = rawState.trim().toLowerCase();
1990
2061
  if (state !== "active" && state !== "blocked" && state !== "done") {
1991
2062
  emit.error({
@@ -2006,6 +2077,19 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
2006
2077
  return;
2007
2078
  }
2008
2079
  if (!opts.sessionId) ensureAdapterSession(root);
2080
+ if (opts.sessionId) {
2081
+ const refusal = forkAncestorRefusal(
2082
+ root,
2083
+ [opts.sessionId],
2084
+ opts.forceAncestor,
2085
+ "change the lifecycle of",
2086
+ );
2087
+ if (refusal) {
2088
+ emit.error({ code: "session_is_fork_ancestor", message: refusal });
2089
+ process.exitCode = 1;
2090
+ return;
2091
+ }
2092
+ }
2009
2093
  const myOwner = opts.sessionId ?? resolveOwner() ?? sessionIdentityFromEnv();
2010
2094
  if (!myOwner) {
2011
2095
  emit.error(
@@ -2383,8 +2467,15 @@ function runStatus(opts: {
2383
2467
 
2384
2468
  const { livePeers, stale: peersStale } = collectStatusPeerHealth(root, myOwner);
2385
2469
 
2386
- // Sort: file-holders first (by file count desc), then idle peers by recency.
2470
+ // Recorded fork relations label the parent and forks so a fork cannot read
2471
+ // its live parent as its own earlier session; they sort first.
2472
+ const relations = forkRelationLabels(root, myOwner, new Set(livePeers.map((p) => p.instance_id)));
2473
+ // Sort: related sessions, then file-holders (by file count desc), then idle
2474
+ // peers by recency.
2387
2475
  livePeers.sort((a, b) => {
2476
+ const ar = relations.has(a.instance_id) ? 1 : 0;
2477
+ const br = relations.has(b.instance_id) ? 1 : 0;
2478
+ if (ar !== br) return br - ar;
2388
2479
  const af = a.files_touched?.length ?? 0;
2389
2480
  const bf = b.files_touched?.length ?? 0;
2390
2481
  if (af !== bf) return bf - af;
@@ -2399,7 +2490,7 @@ function runStatus(opts: {
2399
2490
  );
2400
2491
  // Cross-machine presence (ADR 0016): sessions on other machines, advisory.
2401
2492
  const remoteMachines = readRemoteMachines(root);
2402
- const peersStr = formatPeers(livePeers, 4, peersStale, remoteMachines);
2493
+ const peersStr = formatPeers(livePeers, 4, peersStale, remoteMachines, relations);
2403
2494
 
2404
2495
  const ctxUsage = readContextUsage(hb.native_session_id ?? hb.session_id, hb.platform);
2405
2496
  let ctxStr: string;
@@ -2593,13 +2684,15 @@ function formatPeers(
2593
2684
  cap: number,
2594
2685
  staleCount: number,
2595
2686
  remoteMachines: RemoteMachine[] = [],
2687
+ relations: Map<string, string> = new Map(),
2596
2688
  ): string {
2597
2689
  if (peers.length === 0 && staleCount === 0 && remoteMachines.length === 0) return "none";
2598
2690
  const labels = peers.map((p) => {
2599
2691
  const name = p.name || "unnamed";
2600
2692
  const plat = formatPlatformLabel(p.platform);
2601
2693
  const files = p.files_touched?.length ?? 0;
2602
- const base = `${name} (${plat})`;
2694
+ const relation = relations.get(p.instance_id);
2695
+ const base = relation ? `${name} (${plat}, ${relation})` : `${name} (${plat})`;
2603
2696
  return files > 0 ? `${base}, ${files} files` : base;
2604
2697
  });
2605
2698
  let main: string;
@@ -5230,6 +5323,13 @@ function runHeal(opts: {
5230
5323
  }
5231
5324
 
5232
5325
  if (proc.status !== 0) {
5326
+ const block = liveAuthorityBlockV3(root, owner);
5327
+ if (block) {
5328
+ emitHealFailure(
5329
+ "authority_blocked",
5330
+ `${describeLiveAuthorityBlockV3(block)}; a cache rebuild cannot restore authority the ledger withholds; ${liveAuthorityBlockHintV3(resolveBinName(root))}`,
5331
+ );
5332
+ }
5233
5333
  emitHealFailure(
5234
5334
  healFailureReason(root, owner, sessionId, healAdapter),
5235
5335
  spawnFailureMessage(proc, `agent-coord ${action}`),