harnery 0.43.0 → 0.45.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 (323) hide show
  1. package/README.md +3 -3
  2. package/dist/commander.js +1 -0
  3. package/dist/commands/agents.d.ts.map +1 -1
  4. package/dist/commands/agents.js +41 -48
  5. package/dist/commands/browse.d.ts.map +1 -1
  6. package/dist/commands/browse.js +38 -11
  7. package/dist/commands/diagnostics.d.ts.map +1 -1
  8. package/dist/commands/diagnostics.js +10 -0
  9. package/dist/commands/doctor.d.ts +12 -0
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/doctor.js +22 -7
  12. package/dist/commands/files.d.ts +2 -2
  13. package/dist/commands/files.d.ts.map +1 -1
  14. package/dist/commands/files.js +7 -4
  15. package/dist/commands/governor.js +22 -10
  16. package/dist/commands/inbox.d.ts +1 -1
  17. package/dist/commands/inbox.d.ts.map +1 -1
  18. package/dist/commands/inbox.js +55 -2
  19. package/dist/commands/rm.d.ts.map +1 -1
  20. package/dist/commands/rm.js +28 -4
  21. package/dist/commands/servers.d.ts.map +1 -1
  22. package/dist/commands/servers.js +26 -17
  23. package/dist/commands/supervisor.d.ts.map +1 -1
  24. package/dist/commands/supervisor.js +7 -2
  25. package/dist/commands/toc.d.ts +14 -0
  26. package/dist/commands/toc.d.ts.map +1 -1
  27. package/dist/commands/toc.js +7 -4
  28. package/dist/commands/tunnel.d.ts +15 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +453 -153
  31. package/dist/commands/turn-rollback.d.ts +5 -0
  32. package/dist/commands/turn-rollback.d.ts.map +1 -0
  33. package/dist/commands/turn-rollback.js +76 -0
  34. package/dist/commands/workflow.d.ts.map +1 -1
  35. package/dist/commands/workflow.js +68 -30
  36. package/dist/core/agents/coord-client.d.ts.map +1 -1
  37. package/dist/core/agents/coord-client.js +18 -0
  38. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  39. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  40. package/dist/core/agents/live-authority-v3.js +11 -0
  41. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  42. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  43. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  44. package/dist/core/agents/mailbox.d.ts +9 -8
  45. package/dist/core/agents/mailbox.d.ts.map +1 -1
  46. package/dist/core/agents/mailbox.js +24 -17
  47. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  48. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/prompt-context.js +8 -12
  50. package/dist/core/agents/render/session-context.d.ts +2 -8
  51. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  52. package/dist/core/agents/render/session-context.js +7 -30
  53. package/dist/core/agents/rules/stop-hook.js +3 -3
  54. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  55. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  56. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  57. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  58. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  59. package/dist/core/agents/state/pidmap.js +5 -0
  60. package/dist/core/agents/state/proc-start.d.ts +19 -1
  61. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  62. package/dist/core/agents/state/proc-start.js +46 -1
  63. package/dist/core/agents/state/process-handle.d.ts +26 -0
  64. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  65. package/dist/core/agents/state/process-handle.js +121 -0
  66. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  67. package/dist/core/agents/state/stale-sweep.js +180 -70
  68. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  69. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  70. package/dist/core/agents/state/unchanged-file.js +390 -0
  71. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  72. package/dist/core/artifacts/delivery-card.js +2 -1
  73. package/dist/core/context/index.d.ts +22 -10
  74. package/dist/core/context/index.d.ts.map +1 -1
  75. package/dist/core/context/index.js +381 -119
  76. package/dist/core/context/observation.d.ts +26 -0
  77. package/dist/core/context/observation.d.ts.map +1 -0
  78. package/dist/core/context/observation.js +11 -0
  79. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  80. package/dist/core/diagnostics/bundle.js +25 -14
  81. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  82. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  83. package/dist/core/diagnostics/path-identity.js +12 -0
  84. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  85. package/dist/core/diagnostics/sanitize.js +4 -3
  86. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  87. package/dist/core/events/v3/authority-outbox.js +29 -0
  88. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  89. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  90. package/dist/core/events/v3/generation-custody.js +50 -0
  91. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  92. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  93. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  94. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  95. package/dist/core/events/v3/producers/recorder.js +77 -64
  96. package/dist/core/governor/planning.d.ts.map +1 -1
  97. package/dist/core/governor/planning.js +14 -1
  98. package/dist/core/governor/service-read.d.ts +10 -2
  99. package/dist/core/governor/service-read.d.ts.map +1 -1
  100. package/dist/core/governor/service-read.js +105 -22
  101. package/dist/core/governor/service.d.ts +20 -2
  102. package/dist/core/governor/service.d.ts.map +1 -1
  103. package/dist/core/governor/service.js +72 -47
  104. package/dist/core/hooks/cli.js +33 -5
  105. package/dist/core/inbox/contract.d.ts +2 -1
  106. package/dist/core/inbox/contract.d.ts.map +1 -1
  107. package/dist/core/inbox/index.d.ts +2 -0
  108. package/dist/core/inbox/index.d.ts.map +1 -1
  109. package/dist/core/inbox/index.js +2 -0
  110. package/dist/core/inbox/peer-messages.d.ts +37 -0
  111. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  112. package/dist/core/inbox/peer-messages.js +72 -0
  113. package/dist/core/inbox/service.d.ts +8 -1
  114. package/dist/core/inbox/service.d.ts.map +1 -1
  115. package/dist/core/inbox/service.js +67 -21
  116. package/dist/core/inbox/targets.d.ts +22 -0
  117. package/dist/core/inbox/targets.d.ts.map +1 -0
  118. package/dist/core/inbox/targets.js +108 -0
  119. package/dist/core/inbox/watcher.js +7 -3
  120. package/dist/core/journal/index.d.ts.map +1 -1
  121. package/dist/core/journal/index.js +17 -2
  122. package/dist/core/resources/contract.d.ts +3 -0
  123. package/dist/core/resources/contract.d.ts.map +1 -1
  124. package/dist/core/resources/service-status.d.ts +14 -1
  125. package/dist/core/resources/service-status.d.ts.map +1 -1
  126. package/dist/core/resources/service-status.js +23 -13
  127. package/dist/core/resources/service.d.ts +7 -13
  128. package/dist/core/resources/service.d.ts.map +1 -1
  129. package/dist/core/resources/service.js +34 -91
  130. package/dist/core/resources/status.d.ts +2 -5
  131. package/dist/core/resources/status.d.ts.map +1 -1
  132. package/dist/core/resources/status.js +36 -7
  133. package/dist/core/semantic/service.d.ts.map +1 -1
  134. package/dist/core/semantic/service.js +2 -9
  135. package/dist/core/servers/index.d.ts +34 -22
  136. package/dist/core/servers/index.d.ts.map +1 -1
  137. package/dist/core/servers/index.js +297 -144
  138. package/dist/core/servers/tunnels.d.ts +7 -7
  139. package/dist/core/servers/tunnels.d.ts.map +1 -1
  140. package/dist/core/servers/tunnels.js +47 -10
  141. package/dist/core/supervisor/contract.d.ts +12 -0
  142. package/dist/core/supervisor/contract.d.ts.map +1 -1
  143. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  144. package/dist/core/supervisor/explanations.js +2 -1
  145. package/dist/core/supervisor/findings.d.ts.map +1 -1
  146. package/dist/core/supervisor/findings.js +54 -9
  147. package/dist/core/supervisor/service.d.ts +7 -1
  148. package/dist/core/supervisor/service.d.ts.map +1 -1
  149. package/dist/core/supervisor/service.js +59 -29
  150. package/dist/core/supervisor/services.d.ts.map +1 -1
  151. package/dist/core/supervisor/services.js +22 -1
  152. package/dist/core/supervisor/status.d.ts +17 -2
  153. package/dist/core/supervisor/status.d.ts.map +1 -1
  154. package/dist/core/supervisor/status.js +70 -12
  155. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  156. package/dist/core/supervisor/timeline.js +2 -1
  157. package/dist/core/turn-rollback/content.d.ts +53 -0
  158. package/dist/core/turn-rollback/content.d.ts.map +1 -0
  159. package/dist/core/turn-rollback/content.js +396 -0
  160. package/dist/core/turn-rollback/index.d.ts +72 -0
  161. package/dist/core/turn-rollback/index.d.ts.map +1 -0
  162. package/dist/core/turn-rollback/index.js +443 -0
  163. package/dist/core/work/state.js +7 -7
  164. package/dist/core/workflow/engine.d.ts.map +1 -1
  165. package/dist/core/workflow/engine.js +105 -32
  166. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  167. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  168. package/dist/core/workflow/failure-evidence.js +114 -0
  169. package/dist/core/workflow/index.d.ts +5 -3
  170. package/dist/core/workflow/index.d.ts.map +1 -1
  171. package/dist/core/workflow/index.js +3 -2
  172. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  173. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  174. package/dist/core/workflow/live-session-v3.js +126 -5
  175. package/dist/core/workflow/proof.d.ts +5 -5
  176. package/dist/core/workflow/proof.d.ts.map +1 -1
  177. package/dist/core/workflow/proof.js +115 -20
  178. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  179. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  180. package/dist/core/workflow/recovery-audit.js +116 -0
  181. package/dist/core/workflow/run-state.d.ts +20 -1
  182. package/dist/core/workflow/run-state.d.ts.map +1 -1
  183. package/dist/core/workflow/run-state.js +141 -32
  184. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  185. package/dist/core/workflow/spawn-claude.js +19 -0
  186. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  187. package/dist/core/workflow/spawn-codex.js +13 -0
  188. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  189. package/dist/core/workflow/spawn-cursor.js +19 -0
  190. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  191. package/dist/core/workflow/spawn-opencode.js +14 -0
  192. package/dist/core/workflow/types.d.ts +64 -17
  193. package/dist/core/workflow/types.d.ts.map +1 -1
  194. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  195. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  196. package/dist/core/workflow/workspaces/index.js +0 -1
  197. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  198. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  199. package/dist/core/workflow/workspaces/leases.js +30 -16
  200. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  201. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  202. package/dist/core/workflow/workspaces/local-git.js +328 -8
  203. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  204. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  205. package/dist/core/workflow/workspaces/ownership.js +194 -0
  206. package/dist/core/workflow/workspaces/reclaim.d.ts +40 -0
  207. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  208. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  209. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  210. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  211. package/dist/core/workflow/workspaces/recovery.js +42 -10
  212. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  213. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  214. package/dist/core/workflow/workspaces/state.js +21 -1
  215. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  216. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  217. package/dist/lib/browser/windows-chrome.js +262 -73
  218. package/dist/lib/docs-links.d.ts.map +1 -1
  219. package/dist/lib/docs-links.js +45 -2
  220. package/dist/lib/guarded-remove.d.ts +7 -0
  221. package/dist/lib/guarded-remove.d.ts.map +1 -1
  222. package/dist/lib/guarded-remove.js +106 -12
  223. package/dist/lib/identities/assume.d.ts.map +1 -1
  224. package/dist/lib/identities/assume.js +4 -0
  225. package/dist/lib/tunnel/state.d.ts +13 -6
  226. package/dist/lib/tunnel/state.d.ts.map +1 -1
  227. package/dist/lib/tunnel/state.js +58 -12
  228. package/dist/lib/zip/index.d.ts +41 -0
  229. package/dist/lib/zip/index.d.ts.map +1 -0
  230. package/dist/lib/zip/index.js +398 -0
  231. package/package.json +12 -1
  232. package/src/commander.ts +10 -0
  233. package/src/commands/agents.ts +54 -63
  234. package/src/commands/browse.ts +50 -14
  235. package/src/commands/diagnostics.ts +10 -0
  236. package/src/commands/doctor.ts +26 -7
  237. package/src/commands/files.ts +13 -5
  238. package/src/commands/governor.ts +23 -10
  239. package/src/commands/inbox.ts +52 -2
  240. package/src/commands/rm.ts +43 -4
  241. package/src/commands/servers.ts +27 -26
  242. package/src/commands/supervisor.ts +7 -2
  243. package/src/commands/toc.ts +14 -6
  244. package/src/commands/tunnel.ts +533 -166
  245. package/src/commands/turn-rollback.ts +111 -0
  246. package/src/commands/workflow.ts +89 -30
  247. package/src/core/agents/coord-client.ts +20 -0
  248. package/src/core/agents/live-authority-v3.ts +17 -0
  249. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  250. package/src/core/agents/mailbox.ts +26 -17
  251. package/src/core/agents/render/prompt-context.ts +10 -12
  252. package/src/core/agents/render/session-context.ts +9 -32
  253. package/src/core/agents/rules/stop-hook.ts +3 -3
  254. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  255. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  256. package/src/core/agents/state/pidmap.ts +13 -0
  257. package/src/core/agents/state/proc-start.ts +62 -2
  258. package/src/core/agents/state/process-handle.ts +165 -0
  259. package/src/core/agents/state/stale-sweep.ts +247 -82
  260. package/src/core/agents/state/unchanged-file.ts +456 -0
  261. package/src/core/artifacts/delivery-card.ts +7 -1
  262. package/src/core/context/index.ts +446 -136
  263. package/src/core/context/observation.ts +29 -0
  264. package/src/core/diagnostics/bundle.ts +23 -18
  265. package/src/core/diagnostics/path-identity.ts +15 -0
  266. package/src/core/diagnostics/sanitize.ts +4 -3
  267. package/src/core/events/v3/authority-outbox.ts +56 -0
  268. package/src/core/events/v3/generation-custody.ts +59 -0
  269. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  270. package/src/core/events/v3/producers/recorder.ts +94 -68
  271. package/src/core/governor/planning.ts +20 -8
  272. package/src/core/governor/service-read.ts +128 -20
  273. package/src/core/governor/service.ts +119 -42
  274. package/src/core/hooks/cli.ts +33 -5
  275. package/src/core/inbox/contract.ts +2 -1
  276. package/src/core/inbox/index.ts +2 -0
  277. package/src/core/inbox/peer-messages.ts +94 -0
  278. package/src/core/inbox/service.ts +88 -22
  279. package/src/core/inbox/targets.ts +148 -0
  280. package/src/core/inbox/watcher.ts +8 -9
  281. package/src/core/journal/index.ts +19 -2
  282. package/src/core/resources/contract.ts +4 -0
  283. package/src/core/resources/service-status.ts +35 -12
  284. package/src/core/resources/service.ts +57 -95
  285. package/src/core/resources/status.ts +57 -8
  286. package/src/core/semantic/service.ts +2 -8
  287. package/src/core/servers/index.ts +376 -141
  288. package/src/core/servers/tunnels.ts +57 -13
  289. package/src/core/supervisor/contract.ts +13 -0
  290. package/src/core/supervisor/explanations.ts +4 -1
  291. package/src/core/supervisor/findings.ts +68 -10
  292. package/src/core/supervisor/service.ts +87 -26
  293. package/src/core/supervisor/services.ts +26 -8
  294. package/src/core/supervisor/status.ts +89 -12
  295. package/src/core/supervisor/timeline.ts +2 -1
  296. package/src/core/turn-rollback/content.ts +499 -0
  297. package/src/core/turn-rollback/index.ts +580 -0
  298. package/src/core/work/state.ts +7 -7
  299. package/src/core/workflow/engine.ts +131 -49
  300. package/src/core/workflow/failure-evidence.ts +141 -0
  301. package/src/core/workflow/index.ts +12 -2
  302. package/src/core/workflow/live-session-v3.ts +146 -5
  303. package/src/core/workflow/proof.ts +134 -26
  304. package/src/core/workflow/recovery-audit.ts +181 -0
  305. package/src/core/workflow/run-state.ts +165 -34
  306. package/src/core/workflow/spawn-claude.ts +18 -0
  307. package/src/core/workflow/spawn-codex.ts +13 -0
  308. package/src/core/workflow/spawn-cursor.ts +18 -0
  309. package/src/core/workflow/spawn-opencode.ts +14 -0
  310. package/src/core/workflow/types.ts +51 -17
  311. package/src/core/workflow/workspaces/index.ts +0 -1
  312. package/src/core/workflow/workspaces/leases.ts +48 -17
  313. package/src/core/workflow/workspaces/local-git.ts +388 -7
  314. package/src/core/workflow/workspaces/ownership.ts +232 -0
  315. package/src/core/workflow/workspaces/reclaim.ts +62 -0
  316. package/src/core/workflow/workspaces/recovery.ts +45 -10
  317. package/src/core/workflow/workspaces/state.ts +20 -0
  318. package/src/lib/browser/windows-chrome.ts +346 -86
  319. package/src/lib/docs-links.ts +49 -2
  320. package/src/lib/guarded-remove.ts +132 -14
  321. package/src/lib/identities/assume.ts +4 -0
  322. package/src/lib/tunnel/state.ts +70 -15
  323. package/src/lib/zip/index.ts +483 -0
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { lstatSync, readdirSync, readlinkSync, realpathSync, rmSync, type Stats } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { basename, dirname, isAbsolute, parse, relative, resolve, sep } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
5
5
 
6
6
  export interface RemoveOptions {
7
7
  root: string;
@@ -16,6 +16,12 @@ export interface RemoveOptions {
16
16
  allowProtected?: string[];
17
17
  /** Directories whose own Git metadata and Harnery state may never be allowed (the live workspace). */
18
18
  liveRoots?: string[];
19
+ /** Command-layer validation of the current caller's ownership of this exact managed artifact root. */
20
+ assertOwnedArtifact?: (root: string) => void;
21
+ /** Inspection bound; values above 10,000 require verified ownership of the exact artifact root. */
22
+ entryLimit?: number;
23
+ /** Remove only verified-clean standalone clones inside the caller's owned artifact. */
24
+ cleanClone?: boolean;
19
25
  }
20
26
 
21
27
  export interface RemovalLink {
@@ -30,6 +36,7 @@ export interface RemovalReport {
30
36
  root: string;
31
37
  targets: string[];
32
38
  entries: number;
39
+ entry_limit: number;
33
40
  bytes: number;
34
41
  /** Protected paths removed because the caller named them with allowProtected. */
35
42
  protected: string[];
@@ -39,6 +46,7 @@ export interface RemovalReport {
39
46
 
40
47
  const protectedNames = new Set([".git", ".harnery", ".credentials", ".ssh", ".gnupg"]);
41
48
  const MAX_ENTRIES = 10_000;
49
+ const MAX_OWNED_ARTIFACT_ENTRIES = 100_000;
42
50
 
43
51
  /** Permanent removal is deliberately limited to inspected, untracked local outputs. */
44
52
  export function guardedRemove(
@@ -48,18 +56,48 @@ export function guardedRemove(
48
56
  ): RemovalReport {
49
57
  const cwd = realpathSync(process.cwd());
50
58
  const root = checkedPath(options.root, new Set(), true);
51
- const allowed = allowedProtected(options.allowProtected ?? [], root, options.liveRoots ?? []);
59
+ if (options.cleanClone) {
60
+ if (!options.recursive) throw new Error("Clean clone removal requires --recursive");
61
+ if (options.allowProtected?.length)
62
+ throw new Error("Clean clone removal cannot be combined with --allow-protected");
63
+ if (!options.assertOwnedArtifact)
64
+ throw new Error("Clean clone removal requires one current caller-owned managed artifact");
65
+ options.assertOwnedArtifact(root);
66
+ }
67
+ const entryLimit = options.entryLimit ?? MAX_ENTRIES;
68
+ if (
69
+ !Number.isSafeInteger(entryLimit) ||
70
+ entryLimit < 1 ||
71
+ entryLimit > MAX_OWNED_ARTIFACT_ENTRIES
72
+ )
73
+ throw new Error(`The entry limit must be an integer from 1 to ${MAX_OWNED_ARTIFACT_ENTRIES}`);
74
+ const assertEntryLimitAuthority = () => {
75
+ if (entryLimit <= MAX_ENTRIES) return;
76
+ if (!options.assertOwnedArtifact)
77
+ throw new Error(
78
+ "An entry limit above 10000 requires one current caller-owned managed artifact",
79
+ );
80
+ options.assertOwnedArtifact(root);
81
+ };
82
+ const allowed = allowedProtected(
83
+ options.allowProtected ?? [],
84
+ root,
85
+ options.liveRoots ?? [],
86
+ options.assertOwnedArtifact,
87
+ );
52
88
  const rootStat = lstatSync(root);
53
89
  const rootFingerprint = fingerprint(rootStat);
54
90
  if (!rootStat.isDirectory()) throw new Error("--root must be a directory");
55
91
  if (root === parse(root).root || root === realpathSync(homedir()))
56
92
  throw new Error("--root cannot be a filesystem root or home directory");
93
+ assertEntryLimitAuthority();
57
94
  if (paths.length === 0 || paths.length > 100) throw new Error("Supply 1-100 explicit targets");
58
95
  const targets = paths.map((path) => checkedPath(path, allowed));
59
96
  const snapshots = new Map<string, string>();
60
97
  const removedProtected = new Set<string>();
61
98
  const links: RemovalLink[] = [];
62
99
  const gitDirs = new Set<string>();
100
+ const cleanClones = new Set<string>();
63
101
  let bytes = 0;
64
102
  const devices = new Set<number>();
65
103
 
@@ -110,6 +148,10 @@ export function guardedRemove(
110
148
  if (stat.isDirectory() && !options.recursive)
111
149
  throw new Error(`Directory removal requires --recursive: ${target}`);
112
150
  const device = stat.dev;
151
+ if (options.cleanClone) {
152
+ assertCleanClone(target);
153
+ cleanClones.add(target);
154
+ }
113
155
  devices.add(device);
114
156
  const pending = [target];
115
157
  while (pending.length) {
@@ -118,6 +160,12 @@ export function guardedRemove(
118
160
  if (basename(path) === ARTIFACT_RECORD && insideArtifactWorkspace(path, null))
119
161
  throw new Error(`An artifact's record is managed by the artifact commands: ${path}`);
120
162
  if (protectedNames.has(basename(path).toLowerCase())) {
163
+ if (options.cleanClone && basename(path) === ".git") {
164
+ const clone = dirname(path);
165
+ assertCleanClone(clone);
166
+ cleanClones.add(clone);
167
+ allowed.add(path);
168
+ }
121
169
  if (!allowed.has(path)) throw new Error(protectedMessage(path));
122
170
  removedProtected.add(path);
123
171
  }
@@ -126,9 +174,9 @@ export function guardedRemove(
126
174
  // read, followed, or counted. (The target itself and its ancestors
127
175
  // were already refused if they were links, in checkedPath.)
128
176
  if (path === target) throw new Error(`Path contains a symlink: ${path}`);
129
- if (snapshots.size >= MAX_ENTRIES)
177
+ if (snapshots.size >= entryLimit)
130
178
  throw new Error(
131
- `Removal exceeds the ${MAX_ENTRIES}-entry limit; use the owning cleanup command`,
179
+ `Removal exceeds the ${entryLimit}-entry limit; use the owning cleanup command`,
132
180
  );
133
181
  snapshots.set(path, fingerprint(info));
134
182
  links.push({ path, target: readlinkSync(path) });
@@ -136,9 +184,9 @@ export function guardedRemove(
136
184
  }
137
185
  if (info.dev !== device || (!info.isDirectory() && !info.isFile()))
138
186
  throw new Error(`Mount boundary or special file cannot be removed: ${path}`);
139
- if (snapshots.size >= MAX_ENTRIES)
187
+ if (snapshots.size >= entryLimit)
140
188
  throw new Error(
141
- `Removal exceeds the ${MAX_ENTRIES}-entry limit; use the owning cleanup command`,
189
+ `Removal exceeds the ${entryLimit}-entry limit; use the owning cleanup command`,
142
190
  );
143
191
  snapshots.set(path, fingerprint(info));
144
192
  if (info.isFile()) bytes += info.size;
@@ -172,6 +220,7 @@ export function guardedRemove(
172
220
  root,
173
221
  targets,
174
222
  entries: snapshots.size,
223
+ entry_limit: entryLimit,
175
224
  bytes,
176
225
  protected: [...removedProtected].sort(),
177
226
  links,
@@ -185,11 +234,21 @@ export function guardedRemove(
185
234
  fingerprint(lstatSync(root)) !== rootFingerprint
186
235
  )
187
236
  throw new Error("Allowed root changed during inspection");
237
+ assertEntryLimitAuthority();
238
+ if (options.cleanClone) options.assertOwnedArtifact!(root);
239
+ // Ownership may have been released or changed while the contents were inspected.
240
+ allowedProtected(
241
+ options.allowProtected ?? [],
242
+ root,
243
+ options.liveRoots ?? [],
244
+ options.assertOwnedArtifact,
245
+ );
188
246
  for (const target of targets) {
189
247
  checkedPath(target, allowed);
190
248
  assertUntracked(target);
191
249
  }
192
250
  for (const dir of gitDirs) assertNoUnpushedHistory(dir);
251
+ for (const clone of cleanClones) assertCleanClone(clone);
193
252
  checkClaims(targets);
194
253
  for (const [path, previous] of snapshots) {
195
254
  if (fingerprint(lstatSync(path)) !== previous)
@@ -200,6 +259,47 @@ export function guardedRemove(
200
259
  return { ...report, applied: true };
201
260
  }
202
261
 
262
+ /** A disposable clone must not be the only copy of work or a registered worktree. */
263
+ function assertCleanClone(clone: string): void {
264
+ const metadata = join(clone, ".git");
265
+ const stat = lstatSync(metadata);
266
+ if (!stat.isDirectory() || stat.isSymbolicLink())
267
+ throw new Error(`Clean clone removal requires a standalone .git directory: ${clone}`);
268
+ const git = (args: string[]) => {
269
+ const result = spawnSync("git", ["-c", "core.fsmonitor=false", "-C", clone, ...args], {
270
+ encoding: "utf8",
271
+ timeout: 10_000,
272
+ maxBuffer: 4 * 1024 * 1024,
273
+ env: {
274
+ ...process.env,
275
+ LC_ALL: "C",
276
+ GIT_DIR: undefined,
277
+ GIT_COMMON_DIR: undefined,
278
+ GIT_WORK_TREE: undefined,
279
+ GIT_INDEX_FILE: undefined,
280
+ GIT_OPTIONAL_LOCKS: "0",
281
+ },
282
+ });
283
+ if (result.error || result.status !== 0)
284
+ throw new Error(`Cannot verify clean clone ${clone}: git ${args[0]} failed`);
285
+ return result.stdout;
286
+ };
287
+ if (
288
+ realpathSync(git(["rev-parse", "--show-toplevel"]).trim()) !== clone ||
289
+ realpathSync(resolve(clone, git(["rev-parse", "--git-common-dir"]).trim())) !== metadata
290
+ )
291
+ throw new Error(`Clean clone removal refuses shared repository metadata: ${clone}`);
292
+ const worktrees = git(["worktree", "list", "--porcelain", "-z"])
293
+ .split("\0")
294
+ .filter((entry) => entry.startsWith("worktree "));
295
+ if (worktrees.length !== 1 || worktrees[0] !== `worktree ${clone}`)
296
+ throw new Error(`Clean clone removal refuses registered or linked worktrees: ${clone}`);
297
+ if (git(["status", "--porcelain=v1", "--untracked-files=all"]).trim())
298
+ throw new Error(`Clean clone has uncommitted or untracked work: ${clone}`);
299
+ if (git(["rev-list", "--max-count=1", "HEAD", "--all", "--reflog", "--not", "--remotes"]).trim())
300
+ throw new Error(`Clean clone has HEAD, ref or reflog commits not on any remote: ${clone}`);
301
+ }
302
+
203
303
  /** The metadata file the artifact commands keep in each artifact workspace. */
204
304
  const ARTIFACT_RECORD = ".harnery-artifact.json";
205
305
 
@@ -238,9 +338,14 @@ function insideAllowed(path: string, allowed: ReadonlySet<string>): boolean {
238
338
  * protected path strictly inside the root, reached without symlinks, and
239
339
  * never the Git metadata or Harnery state of a live workspace root.
240
340
  */
241
- function allowedProtected(paths: string[], root: string, liveRoots: string[]): Set<string> {
341
+ function allowedProtected(
342
+ paths: string[],
343
+ root: string,
344
+ liveRoots: string[],
345
+ assertOwnedArtifact?: (root: string) => void,
346
+ ): Set<string> {
242
347
  const allowed = new Set<string>();
243
- const live = liveRoots.map((dir) => resolve(dir));
348
+ const live = liveRoots.map((dir) => realpathSync(dir));
244
349
  for (const input of paths) {
245
350
  if (!input?.trim() || /[\0\r\n*?[\]$]/.test(input))
246
351
  throw new Error("Use a nonempty explicit --allow-protected path without wildcards");
@@ -261,13 +366,26 @@ function allowedProtected(paths: string[], root: string, liveRoots: string[]): S
261
366
  if (!named && !(info.isDirectory() && isRepositoryMetadata(path)))
262
367
  throw new Error(`--allow-protected must name a protected path itself: ${path}`);
263
368
  for (const dir of live) {
264
- const own = [".git", ".harnery"].map((name) => resolve(dir, name));
265
- if (
266
- path === dir ||
267
- strictlyInside(path, dir) ||
268
- own.some((state) => path === state || strictlyInside(state, path))
269
- )
369
+ if (path === dir || strictlyInside(path, dir))
370
+ throw new Error(`The live workspace's own state cannot be allowed: ${path}`);
371
+ for (const name of [".git", ".harnery"]) {
372
+ const state = resolve(dir, name);
373
+ if (path !== state && !strictlyInside(state, path)) continue;
374
+ // Only an explicitly named copied state directory inside one owned
375
+ // artifact may cross the outer live-state ancestor. Other live roots
376
+ // (including a checkout inside that artifact) still veto the path.
377
+ if (
378
+ name === ".harnery" &&
379
+ basename(path) === ".harnery" &&
380
+ info.isDirectory() &&
381
+ dirname(root) === resolve(state, "artifacts") &&
382
+ assertOwnedArtifact
383
+ ) {
384
+ assertOwnedArtifact(root);
385
+ continue;
386
+ }
270
387
  throw new Error(`The live workspace's own state cannot be allowed: ${path}`);
388
+ }
271
389
  }
272
390
  allowed.add(realpathSync(path));
273
391
  }
@@ -140,6 +140,7 @@ export function reclaimAbandonedLocalConflict(
140
140
  let sessionId = conflict.instance_id;
141
141
  let ageSecs: number | undefined;
142
142
  const liveRow = readLiveCoordinationRow(coordRoot, conflict.instance_id);
143
+ let expectedGenerationId = liveRow?.v3_generation_id ?? null;
143
144
  if (liveRow) {
144
145
  adapter = adapterOf(liveRow.platform);
145
146
  sessionId = liveRow.session_id || conflict.instance_id;
@@ -153,7 +154,9 @@ export function reclaimAbandonedLocalConflict(
153
154
  // It is required to join the exact producer state when recording the sweep.
154
155
  adapter = adapterOf(hb.platform);
155
156
  sessionId = hb.session_id || conflict.instance_id;
157
+ expectedGenerationId = hb.v3_generation_id ?? null;
156
158
  } catch {
159
+ expectedGenerationId = null;
157
160
  /* content unreadable — still reclaim the file */
158
161
  }
159
162
  try {
@@ -168,6 +171,7 @@ export function reclaimAbandonedLocalConflict(
168
171
  owner: conflict.instance_id,
169
172
  nativeSessionId: sessionId,
170
173
  adapter,
174
+ expectedGenerationId,
171
175
  observation: "stale_heartbeat",
172
176
  ageMs: Math.max(0, (ageSecs ?? 0) * 1_000),
173
177
  });
@@ -2,6 +2,7 @@
2
2
  // <cwd>/.cache/tunnel/; gitignored, so the allowlist is per-machine.
3
3
 
4
4
  import { spawnSync } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
5
6
  import {
6
7
  accessSync,
7
8
  chmodSync,
@@ -10,6 +11,7 @@ import {
10
11
  mkdirSync,
11
12
  readdirSync,
12
13
  readFileSync,
14
+ renameSync,
13
15
  statSync,
14
16
  unlinkSync,
15
17
  writeFileSync,
@@ -104,10 +106,12 @@ export interface TunnelState {
104
106
  provider: TunnelProvider;
105
107
  url: string;
106
108
  gate_pid: number;
109
+ gate_start_token?: string;
110
+ cloudflared_start_token?: string;
111
+ registration_id?: string;
112
+ provider_state?: "pending" | "ready";
107
113
  /** Present for Cloudflare quick tunnels; absent for Tailscale Serve/Funnel. */
108
114
  cloudflared_pid?: number;
109
- /** Optional provider-side process when a provider owns one. */
110
- provider_pid?: number;
111
115
  started_at: string;
112
116
  target: string;
113
117
  vhost: string;
@@ -168,7 +172,15 @@ export function readState(
168
172
  }
169
173
 
170
174
  export function writeState(state: TunnelState, root: string = process.cwd()): void {
171
- writeFileSync(cachePath(stateFile(state.name), root), JSON.stringify(state, null, 2));
175
+ const path = cachePath(stateFile(state.name), root);
176
+ const temporary = `${path}.${randomUUID()}.tmp`;
177
+ try {
178
+ writeFileSync(temporary, JSON.stringify(state, null, 2), { flag: "wx", mode: 0o600 });
179
+ renameSync(temporary, path);
180
+ } catch (error) {
181
+ if (existsSync(temporary)) unlinkSync(temporary);
182
+ throw error;
183
+ }
172
184
  }
173
185
 
174
186
  export function clearState(name: string = DEFAULT_INSTANCE, root: string = process.cwd()): void {
@@ -202,17 +214,60 @@ export function isProcessAlive(pid: number): boolean {
202
214
  }
203
215
  }
204
216
 
205
- export type ProcessAliveCheck = (pid: number) => boolean;
217
+ export type TunnelReadiness = "ready" | "dead" | "unknown";
218
+ export type TunnelProcessObservation = "same" | "absent" | "different" | "unknown";
219
+
220
+ export interface TunnelReadinessVerifier {
221
+ observeProcess(pid: number, originalToken: string): TunnelProcessObservation;
222
+ withRegistration(state: TunnelState, inspect: () => TunnelReadiness): TunnelReadiness;
223
+ }
206
224
 
207
- /** True when both the local gate and its provider-side process are live. */
208
- export function isTunnelStateLive(
225
+ /** Classify saved original witnesses under the verifier's registration custody. */
226
+ export function tunnelReadiness(
209
227
  state: TunnelState,
210
- processAlive: ProcessAliveCheck = isProcessAlive,
211
- ): boolean {
212
- if (!processAlive(state.gate_pid)) return false;
213
- if (state.provider === "tailscale") return true;
214
- const providerPid = state.cloudflared_pid ?? state.provider_pid;
215
- return typeof providerPid === "number" && processAlive(providerPid);
228
+ verifier: TunnelReadinessVerifier,
229
+ ): TunnelReadiness {
230
+ if (
231
+ !state ||
232
+ typeof state !== "object" ||
233
+ Object.hasOwn(state, "provider_pid") ||
234
+ Object.hasOwn(state, "provider_start_token") ||
235
+ typeof state.registration_id !== "string" ||
236
+ !state.registration_id ||
237
+ !Number.isSafeInteger(state.gate_pid) ||
238
+ state.gate_pid < 1 ||
239
+ typeof state.gate_start_token !== "string" ||
240
+ !state.gate_start_token
241
+ )
242
+ return "unknown";
243
+ const targets = [{ pid: state.gate_pid, token: state.gate_start_token }];
244
+ if (state.provider === "tailscale") {
245
+ if (
246
+ state.provider_state !== "ready" ||
247
+ state.cloudflared_pid !== undefined ||
248
+ state.cloudflared_start_token !== undefined
249
+ )
250
+ return "unknown";
251
+ } else if (state.provider === "cloudflare") {
252
+ if (
253
+ !Number.isSafeInteger(state.cloudflared_pid) ||
254
+ state.cloudflared_pid! < 1 ||
255
+ typeof state.cloudflared_start_token !== "string" ||
256
+ !state.cloudflared_start_token
257
+ )
258
+ return "unknown";
259
+ targets.push({ pid: state.cloudflared_pid!, token: state.cloudflared_start_token });
260
+ } else return "unknown";
261
+ try {
262
+ return verifier.withRegistration(state, () => {
263
+ const observed = targets.map(({ pid, token }) => verifier.observeProcess(pid, token));
264
+ if (observed.every((value) => value === "absent")) return "dead";
265
+ if (observed.every((value) => value === "same")) return "ready";
266
+ return "unknown";
267
+ });
268
+ } catch {
269
+ return "unknown";
270
+ }
216
271
  }
217
272
 
218
273
  export function tunnelTargetPort(state: TunnelState): number | null {
@@ -242,15 +297,15 @@ export function tunnelTargetPort(state: TunnelState): number | null {
242
297
  export function findLiveTunnelForOrigin(
243
298
  port: number,
244
299
  vhost: string,
245
- root: string = process.cwd(),
246
- processAlive: ProcessAliveCheck = isProcessAlive,
300
+ root: string,
301
+ verifier: TunnelReadinessVerifier,
247
302
  ): TunnelState | null {
248
303
  return (
249
304
  listStates(root).find(
250
305
  (state) =>
251
306
  tunnelTargetPort(state) === port &&
252
307
  state.vhost === vhost &&
253
- isTunnelStateLive(state, processAlive),
308
+ tunnelReadiness(state, verifier) === "ready",
254
309
  ) ?? null
255
310
  );
256
311
  }