harnery 0.42.0 → 0.44.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 (343) hide show
  1. package/README.md +3 -3
  2. package/dist/commander.d.ts +16 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +18 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +95 -52
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +38 -11
  9. package/dist/commands/diagnostics.d.ts.map +1 -1
  10. package/dist/commands/diagnostics.js +10 -0
  11. package/dist/commands/doctor.d.ts +12 -0
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/doctor.js +22 -7
  14. package/dist/commands/governor.js +22 -10
  15. package/dist/commands/inbox.d.ts +1 -1
  16. package/dist/commands/inbox.d.ts.map +1 -1
  17. package/dist/commands/inbox.js +55 -2
  18. package/dist/commands/rm.d.ts.map +1 -1
  19. package/dist/commands/rm.js +28 -4
  20. package/dist/commands/servers.d.ts.map +1 -1
  21. package/dist/commands/servers.js +16 -1
  22. package/dist/commands/supervisor.d.ts.map +1 -1
  23. package/dist/commands/supervisor.js +7 -2
  24. package/dist/commands/toc.d.ts +14 -0
  25. package/dist/commands/toc.d.ts.map +1 -1
  26. package/dist/commands/toc.js +7 -4
  27. package/dist/commands/tunnel.d.ts +65 -0
  28. package/dist/commands/tunnel.d.ts.map +1 -1
  29. package/dist/commands/tunnel.js +290 -101
  30. package/dist/commands/web-fetch.d.ts +3 -2
  31. package/dist/commands/web-fetch.d.ts.map +1 -1
  32. package/dist/commands/web-fetch.js +25 -19
  33. package/dist/commands/web.d.ts +7 -1
  34. package/dist/commands/web.d.ts.map +1 -1
  35. package/dist/commands/web.js +9 -3
  36. package/dist/commands/workflow.js +24 -30
  37. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  38. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  39. package/dist/core/agents/live-authority-v3.js +11 -0
  40. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  41. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  42. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  43. package/dist/core/agents/mailbox.d.ts +9 -8
  44. package/dist/core/agents/mailbox.d.ts.map +1 -1
  45. package/dist/core/agents/mailbox.js +24 -17
  46. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  47. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  48. package/dist/core/agents/render/prompt-context.js +19 -17
  49. package/dist/core/agents/render/session-context.d.ts +2 -8
  50. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  51. package/dist/core/agents/render/session-context.js +64 -43
  52. package/dist/core/agents/rules/stop-hook.js +3 -3
  53. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  54. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  55. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  56. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  57. package/dist/core/agents/state/names.d.ts +43 -3
  58. package/dist/core/agents/state/names.d.ts.map +1 -1
  59. package/dist/core/agents/state/names.js +87 -3
  60. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  61. package/dist/core/agents/state/pidmap.js +5 -0
  62. package/dist/core/agents/state/proc-start.d.ts +19 -0
  63. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  64. package/dist/core/agents/state/proc-start.js +41 -0
  65. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  66. package/dist/core/agents/state/stale-sweep.js +180 -70
  67. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  68. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  69. package/dist/core/agents/state/unchanged-file.js +390 -0
  70. package/dist/core/context/index.d.ts +22 -10
  71. package/dist/core/context/index.d.ts.map +1 -1
  72. package/dist/core/context/index.js +381 -119
  73. package/dist/core/context/observation.d.ts +26 -0
  74. package/dist/core/context/observation.d.ts.map +1 -0
  75. package/dist/core/context/observation.js +11 -0
  76. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  77. package/dist/core/diagnostics/bundle.js +25 -14
  78. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  79. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  80. package/dist/core/diagnostics/path-identity.js +12 -0
  81. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  82. package/dist/core/diagnostics/sanitize.js +4 -3
  83. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  84. package/dist/core/events/v3/authority-outbox.js +29 -0
  85. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  86. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  87. package/dist/core/events/v3/generation-custody.js +50 -0
  88. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  89. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  90. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  91. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  92. package/dist/core/events/v3/producers/recorder.js +77 -64
  93. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  94. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  95. package/dist/core/governor/planning.d.ts.map +1 -1
  96. package/dist/core/governor/planning.js +14 -1
  97. package/dist/core/governor/service-read.d.ts +10 -2
  98. package/dist/core/governor/service-read.d.ts.map +1 -1
  99. package/dist/core/governor/service-read.js +105 -22
  100. package/dist/core/governor/service.d.ts +20 -2
  101. package/dist/core/governor/service.d.ts.map +1 -1
  102. package/dist/core/governor/service.js +72 -47
  103. package/dist/core/hooks/cli.js +120 -23
  104. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  105. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  106. package/dist/core/hooks/fork-lineage.js +62 -0
  107. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  108. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  109. package/dist/core/hooks/resolve/transcript.js +84 -0
  110. package/dist/core/hooks/shell-rm.d.ts +2 -0
  111. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  112. package/dist/core/hooks/shell-rm.js +105 -0
  113. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  114. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  115. package/dist/core/hooks/shell-tokens.js +97 -0
  116. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  117. package/dist/core/hooks/shell-waiter.js +3 -93
  118. package/dist/core/inbox/contract.d.ts +2 -1
  119. package/dist/core/inbox/contract.d.ts.map +1 -1
  120. package/dist/core/inbox/index.d.ts +2 -0
  121. package/dist/core/inbox/index.d.ts.map +1 -1
  122. package/dist/core/inbox/index.js +2 -0
  123. package/dist/core/inbox/peer-messages.d.ts +37 -0
  124. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  125. package/dist/core/inbox/peer-messages.js +72 -0
  126. package/dist/core/inbox/service.d.ts +8 -1
  127. package/dist/core/inbox/service.d.ts.map +1 -1
  128. package/dist/core/inbox/service.js +67 -21
  129. package/dist/core/inbox/targets.d.ts +22 -0
  130. package/dist/core/inbox/targets.d.ts.map +1 -0
  131. package/dist/core/inbox/targets.js +108 -0
  132. package/dist/core/inbox/watcher.js +7 -3
  133. package/dist/core/journal/index.d.ts.map +1 -1
  134. package/dist/core/journal/index.js +17 -2
  135. package/dist/core/resources/contract.d.ts +3 -0
  136. package/dist/core/resources/contract.d.ts.map +1 -1
  137. package/dist/core/resources/service-status.d.ts +14 -1
  138. package/dist/core/resources/service-status.d.ts.map +1 -1
  139. package/dist/core/resources/service-status.js +23 -13
  140. package/dist/core/resources/service.d.ts +9 -1
  141. package/dist/core/resources/service.d.ts.map +1 -1
  142. package/dist/core/resources/service.js +47 -27
  143. package/dist/core/resources/status.d.ts +2 -5
  144. package/dist/core/resources/status.d.ts.map +1 -1
  145. package/dist/core/resources/status.js +36 -7
  146. package/dist/core/semantic/contract.d.ts +5 -5
  147. package/dist/core/servers/index.d.ts +24 -13
  148. package/dist/core/servers/index.d.ts.map +1 -1
  149. package/dist/core/servers/index.js +132 -92
  150. package/dist/core/servers/tunnels.d.ts +3 -5
  151. package/dist/core/servers/tunnels.d.ts.map +1 -1
  152. package/dist/core/servers/tunnels.js +3 -9
  153. package/dist/core/storage/process-log.d.ts +12 -0
  154. package/dist/core/storage/process-log.d.ts.map +1 -1
  155. package/dist/core/storage/process-log.js +10 -2
  156. package/dist/core/supervisor/contract.d.ts +12 -0
  157. package/dist/core/supervisor/contract.d.ts.map +1 -1
  158. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  159. package/dist/core/supervisor/explanations.js +2 -1
  160. package/dist/core/supervisor/findings.d.ts.map +1 -1
  161. package/dist/core/supervisor/findings.js +54 -9
  162. package/dist/core/supervisor/service.d.ts +9 -1
  163. package/dist/core/supervisor/service.d.ts.map +1 -1
  164. package/dist/core/supervisor/service.js +59 -29
  165. package/dist/core/supervisor/services.d.ts.map +1 -1
  166. package/dist/core/supervisor/services.js +22 -1
  167. package/dist/core/supervisor/status.d.ts +17 -2
  168. package/dist/core/supervisor/status.d.ts.map +1 -1
  169. package/dist/core/supervisor/status.js +70 -12
  170. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  171. package/dist/core/supervisor/timeline.js +2 -1
  172. package/dist/core/work/state.js +7 -7
  173. package/dist/core/workflow/engine.d.ts.map +1 -1
  174. package/dist/core/workflow/engine.js +105 -32
  175. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  176. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  177. package/dist/core/workflow/failure-evidence.js +114 -0
  178. package/dist/core/workflow/index.d.ts +1 -1
  179. package/dist/core/workflow/index.d.ts.map +1 -1
  180. package/dist/core/workflow/index.js +1 -1
  181. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  182. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  183. package/dist/core/workflow/live-session-v3.js +126 -5
  184. package/dist/core/workflow/proof.d.ts +5 -5
  185. package/dist/core/workflow/proof.d.ts.map +1 -1
  186. package/dist/core/workflow/proof.js +115 -20
  187. package/dist/core/workflow/run-state.d.ts +3 -1
  188. package/dist/core/workflow/run-state.d.ts.map +1 -1
  189. package/dist/core/workflow/run-state.js +31 -27
  190. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  191. package/dist/core/workflow/spawn-claude.js +19 -0
  192. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  193. package/dist/core/workflow/spawn-codex.js +13 -0
  194. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  195. package/dist/core/workflow/spawn-cursor.js +19 -0
  196. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  197. package/dist/core/workflow/spawn-opencode.js +14 -0
  198. package/dist/core/workflow/types.d.ts +64 -17
  199. package/dist/core/workflow/types.d.ts.map +1 -1
  200. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  201. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  202. package/dist/core/workflow/workspaces/index.js +0 -1
  203. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  204. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  205. package/dist/core/workflow/workspaces/leases.js +30 -16
  206. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  207. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  208. package/dist/core/workflow/workspaces/local-git.js +270 -6
  209. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  210. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  211. package/dist/core/workflow/workspaces/ownership.js +194 -0
  212. package/dist/core/workflow/workspaces/reclaim.d.ts +35 -0
  213. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  214. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  215. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  216. package/dist/core/workflow/workspaces/recovery.js +21 -13
  217. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  218. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  219. package/dist/core/workflow/workspaces/state.js +21 -1
  220. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  221. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  222. package/dist/lib/browser/windows-chrome.js +262 -73
  223. package/dist/lib/docs-links.d.ts.map +1 -1
  224. package/dist/lib/docs-links.js +45 -2
  225. package/dist/lib/guarded-remove.d.ts +7 -0
  226. package/dist/lib/guarded-remove.d.ts.map +1 -1
  227. package/dist/lib/guarded-remove.js +106 -12
  228. package/dist/lib/identities/assume.d.ts.map +1 -1
  229. package/dist/lib/identities/assume.js +4 -0
  230. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  231. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  232. package/dist/lib/tunnel/allowlist.js +204 -0
  233. package/dist/lib/tunnel/current-address.d.ts +41 -0
  234. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  235. package/dist/lib/tunnel/current-address.js +123 -0
  236. package/dist/lib/tunnel/gate.js +13 -6
  237. package/dist/lib/tunnel/processes.d.ts +39 -0
  238. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  239. package/dist/lib/tunnel/processes.js +121 -0
  240. package/dist/lib/tunnel/state.d.ts +28 -3
  241. package/dist/lib/tunnel/state.d.ts.map +1 -1
  242. package/dist/lib/tunnel/state.js +98 -27
  243. package/dist/lib/zip/index.d.ts +41 -0
  244. package/dist/lib/zip/index.d.ts.map +1 -0
  245. package/dist/lib/zip/index.js +398 -0
  246. package/package.json +8 -1
  247. package/src/commander.ts +32 -0
  248. package/src/commands/agents.ts +151 -74
  249. package/src/commands/browse.ts +50 -14
  250. package/src/commands/diagnostics.ts +10 -0
  251. package/src/commands/doctor.ts +26 -7
  252. package/src/commands/governor.ts +23 -10
  253. package/src/commands/inbox.ts +52 -2
  254. package/src/commands/rm.ts +43 -4
  255. package/src/commands/servers.ts +16 -4
  256. package/src/commands/supervisor.ts +7 -2
  257. package/src/commands/toc.ts +14 -6
  258. package/src/commands/tunnel.ts +394 -116
  259. package/src/commands/web-fetch.ts +30 -19
  260. package/src/commands/web.ts +9 -3
  261. package/src/commands/workflow.ts +30 -30
  262. package/src/core/agents/live-authority-v3.ts +17 -0
  263. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  264. package/src/core/agents/mailbox.ts +26 -17
  265. package/src/core/agents/render/prompt-context.ts +35 -20
  266. package/src/core/agents/render/session-context.ts +92 -48
  267. package/src/core/agents/rules/stop-hook.ts +3 -3
  268. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  269. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  270. package/src/core/agents/state/names.ts +117 -6
  271. package/src/core/agents/state/pidmap.ts +13 -0
  272. package/src/core/agents/state/proc-start.ts +57 -0
  273. package/src/core/agents/state/stale-sweep.ts +247 -82
  274. package/src/core/agents/state/unchanged-file.ts +456 -0
  275. package/src/core/context/index.ts +446 -136
  276. package/src/core/context/observation.ts +29 -0
  277. package/src/core/diagnostics/bundle.ts +23 -18
  278. package/src/core/diagnostics/path-identity.ts +15 -0
  279. package/src/core/diagnostics/sanitize.ts +4 -3
  280. package/src/core/events/v3/authority-outbox.ts +56 -0
  281. package/src/core/events/v3/generation-custody.ts +59 -0
  282. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  283. package/src/core/events/v3/producers/recorder.ts +94 -68
  284. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  285. package/src/core/governor/planning.ts +20 -8
  286. package/src/core/governor/service-read.ts +128 -20
  287. package/src/core/governor/service.ts +119 -42
  288. package/src/core/hooks/cli.ts +139 -22
  289. package/src/core/hooks/fork-lineage.ts +82 -0
  290. package/src/core/hooks/resolve/transcript.ts +88 -0
  291. package/src/core/hooks/shell-rm.ts +103 -0
  292. package/src/core/hooks/shell-tokens.ts +96 -0
  293. package/src/core/hooks/shell-waiter.ts +4 -91
  294. package/src/core/inbox/contract.ts +2 -1
  295. package/src/core/inbox/index.ts +2 -0
  296. package/src/core/inbox/peer-messages.ts +94 -0
  297. package/src/core/inbox/service.ts +88 -22
  298. package/src/core/inbox/targets.ts +148 -0
  299. package/src/core/inbox/watcher.ts +8 -9
  300. package/src/core/journal/index.ts +19 -2
  301. package/src/core/resources/contract.ts +4 -0
  302. package/src/core/resources/service-status.ts +35 -12
  303. package/src/core/resources/service.ts +71 -24
  304. package/src/core/resources/status.ts +57 -8
  305. package/src/core/servers/index.ts +177 -91
  306. package/src/core/servers/tunnels.ts +3 -11
  307. package/src/core/storage/process-log.ts +17 -2
  308. package/src/core/supervisor/contract.ts +13 -0
  309. package/src/core/supervisor/explanations.ts +4 -1
  310. package/src/core/supervisor/findings.ts +68 -10
  311. package/src/core/supervisor/service.ts +88 -26
  312. package/src/core/supervisor/services.ts +26 -8
  313. package/src/core/supervisor/status.ts +89 -12
  314. package/src/core/supervisor/timeline.ts +2 -1
  315. package/src/core/work/state.ts +7 -7
  316. package/src/core/workflow/engine.ts +131 -49
  317. package/src/core/workflow/failure-evidence.ts +141 -0
  318. package/src/core/workflow/index.ts +0 -1
  319. package/src/core/workflow/live-session-v3.ts +146 -5
  320. package/src/core/workflow/proof.ts +134 -26
  321. package/src/core/workflow/run-state.ts +31 -29
  322. package/src/core/workflow/spawn-claude.ts +18 -0
  323. package/src/core/workflow/spawn-codex.ts +13 -0
  324. package/src/core/workflow/spawn-cursor.ts +18 -0
  325. package/src/core/workflow/spawn-opencode.ts +14 -0
  326. package/src/core/workflow/types.ts +51 -17
  327. package/src/core/workflow/workspaces/index.ts +0 -1
  328. package/src/core/workflow/workspaces/leases.ts +48 -17
  329. package/src/core/workflow/workspaces/local-git.ts +315 -5
  330. package/src/core/workflow/workspaces/ownership.ts +232 -0
  331. package/src/core/workflow/workspaces/reclaim.ts +58 -0
  332. package/src/core/workflow/workspaces/recovery.ts +25 -13
  333. package/src/core/workflow/workspaces/state.ts +20 -0
  334. package/src/lib/browser/windows-chrome.ts +346 -86
  335. package/src/lib/docs-links.ts +49 -2
  336. package/src/lib/guarded-remove.ts +132 -14
  337. package/src/lib/identities/assume.ts +4 -0
  338. package/src/lib/tunnel/allowlist.ts +208 -0
  339. package/src/lib/tunnel/current-address.ts +158 -0
  340. package/src/lib/tunnel/gate.ts +16 -8
  341. package/src/lib/tunnel/processes.ts +149 -0
  342. package/src/lib/tunnel/state.ts +123 -27
  343. package/src/lib/zip/index.ts +483 -0
@@ -7,18 +7,19 @@ import type { Cookie } from "../cookies/client.ts";
7
7
  /**
8
8
  * Sign-in through the Windows host's Google Chrome when running under WSL.
9
9
  *
10
- * Some sign-in flows (X is the reported case) reject every browser running
11
- * inside WSL, including a plain Linux Google Chrome with nothing attached:
12
- * the sign-in request comes back "429 Too Many Requests" while the same
13
- * account signs in from the Windows desktop browser without trouble. The
14
- * Windows Chrome is the client those flows accept, so `browse --login
15
- * --plain` under WSL opens it on a dedicated Windows-side profile. The
16
- * person signs in there with nothing attached. Afterwards the profile is
17
- * reopened headless for a moment, its cookies for the sign-in site are read
18
- * through the DevTools protocol, and they are merged into the shared cookie
19
- * store that later `browse` runs attach. The Windows profile cannot be shared
20
- * with Linux Chromium directly because Windows encrypts its cookie database
21
- * with keys only Windows can read.
10
+ * Some sign-in flows reject every browser running inside WSL, including a
11
+ * plain Linux Google Chrome with nothing attached: the sign-in request comes
12
+ * back "429 Too Many Requests" while the same account signs in from the
13
+ * Windows desktop browser without trouble. The Windows Chrome is the client
14
+ * those flows accept, so `browse --login --plain` under WSL opens it on a
15
+ * dedicated Windows-side profile. The person signs in there with nothing
16
+ * attached. Afterwards the profile is reopened headless for a moment with its
17
+ * last session restored, its cookies for the sign-in site (browser-session
18
+ * cookies included) and its open pages on that site are read through the
19
+ * DevTools protocol, and the cookies are merged into the shared cookie store
20
+ * that later `browse` runs attach. The Windows profile cannot be shared with
21
+ * Linux Chromium directly because Windows encrypts its cookie database with
22
+ * keys only Windows can read.
22
23
  *
23
24
  * Under WSL2's default NAT networking the Windows loopback port is not
24
25
  * reachable from Linux, so the DevTools conversation runs inside a short
@@ -106,117 +107,376 @@ export function cookiesForDomain(cdpCookies: CdpCookie[], domain: string): Cooki
106
107
  }
107
108
 
108
109
  /**
109
- * PowerShell that does the whole read on the Windows side: start Chrome
110
- * headless on the profile, wait for the DevToolsActivePort file it writes,
111
- * read every cookie with `Storage.getCookies`, write the raw response to
112
- * stdout, and close the browser. WSL never touches the profile folder, which
113
- * it may not be able to list. Every wait carries a deadline.
110
+ * Regular expression, valid in both JavaScript and .NET, that matches a Chrome
111
+ * command line running on `profileWindows`. It matches the `--user-data-dir`
112
+ * argument exactly, with or without quotes around the path, so `...\profile`
113
+ * never matches `...\profile-2`. Quotes are written as `\x22` so the pattern
114
+ * survives being passed to PowerShell as an argument. Case-insensitive use is
115
+ * intended, as Windows paths are.
114
116
  */
115
- export function harvestScript(): string {
117
+ export function profileCommandLinePattern(profileWindows: string): string {
118
+ const path = profileWindows.replace(/\\+$/, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
+ return `--user-data-dir=\\x22?${path}\\\\?(\\x22|\\s|$)`;
120
+ }
121
+
122
+ /** PowerShell function listing the Chrome processes whose command line matches `$Pattern`. */
123
+ const PROFILE_PROCESS_FUNCTION =
124
+ "function Get-ProfileChrome { @(Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" | Where-Object { $_.CommandLine -and $_.CommandLine -match $Pattern }) }";
125
+
126
+ /**
127
+ * PowerShell that throws while any Chrome is still running on the profile: a
128
+ * second Chrome on the same profile hands its work to the first and exits.
129
+ */
130
+ const REFUSE_OPEN_PROFILE_LINES = [
131
+ PROFILE_PROCESS_FUNCTION,
132
+ "$alive = Get-ProfileChrome",
133
+ "if ($alive.Count -gt 0) { throw \"Chrome is still running on the sign-in profile (process $(($alive | ForEach-Object { $_.ProcessId }) -join ', ')). Close that window and try again.\" }",
134
+ ];
135
+
136
+ /**
137
+ * PowerShell that starts Chrome headless on `$Profile` with `extraArgs`, waits
138
+ * for the DevToolsActivePort file it writes, and connects to the browser
139
+ * endpoint, defining `Send`, `Recv`, and `Wait-Reply` for the conversation.
140
+ * It refuses to start while any Chrome is still running on the profile,
141
+ * which would otherwise surface as a confusing startup failure.
142
+ */
143
+ function headlessSessionLines(extraArgs: string[]): string[] {
144
+ const quoted = extraArgs.map((arg) => `'${arg}'`).join(", ");
116
145
  return [
117
- "param([string]$Chrome, [string]$Profile, [int]$StartupSeconds = 30)",
118
- "$ErrorActionPreference = 'Stop'",
146
+ ...REFUSE_OPEN_PROFILE_LINES,
119
147
  "$portFile = Join-Path $Profile 'DevToolsActivePort'",
120
148
  "if (Test-Path $portFile) { Remove-Item $portFile -Force }",
121
- "$args = @('--headless=new', '--no-first-run', '--no-default-browser-check', '--remote-debugging-port=0', \"--user-data-dir=$Profile\", 'about:blank')",
122
- "$proc = Start-Process -FilePath $Chrome -ArgumentList $args -PassThru -WindowStyle Hidden",
149
+ `$chromeArgs = @('--headless=new', '--no-first-run', '--no-default-browser-check', ${quoted ? `${quoted}, ` : ""}'--remote-debugging-port=0', "--user-data-dir=\`"$Profile\`"", 'about:blank')`,
150
+ "$proc = Start-Process -FilePath $Chrome -ArgumentList $chromeArgs -PassThru -WindowStyle Hidden",
123
151
  "$deadline = (Get-Date).AddSeconds($StartupSeconds)",
124
- "while (-not (Test-Path $portFile)) { if ((Get-Date) -gt $deadline) { throw 'Windows Chrome did not open its DevTools port in time.' }; if ($proc.HasExited) { throw 'Windows Chrome exited before opening its DevTools port.' }; Start-Sleep -Milliseconds 250 }",
152
+ "while (-not (Test-Path $portFile)) { if ((Get-Date) -gt $deadline) { throw 'Windows Chrome did not open its DevTools port in time.' }; if ($proc.HasExited) { throw 'Windows Chrome exited before opening its DevTools port; another Chrome may have opened on the sign-in profile.' }; Start-Sleep -Milliseconds 250 }",
125
153
  "Start-Sleep -Milliseconds 200",
126
154
  "$lines = Get-Content $portFile",
127
155
  "$port = [int]$lines[0]; $path = $lines[1]",
128
156
  "$ws = New-Object System.Net.WebSockets.ClientWebSocket",
129
- "$cts = New-Object System.Threading.CancellationTokenSource 20000",
157
+ "$cts = New-Object System.Threading.CancellationTokenSource 30000",
130
158
  '$ws.ConnectAsync([Uri]("ws://127.0.0.1:$port$path"), $cts.Token).Wait()',
131
- "function Send($obj) { $b = [Text.Encoding]::UTF8.GetBytes(($obj | ConvertTo-Json -Compress)); $ws.SendAsync([ArraySegment[byte]]$b, 'Text', $true, $cts.Token).Wait() }",
159
+ "function Send($obj) { $b = [Text.Encoding]::UTF8.GetBytes(($obj | ConvertTo-Json -Compress -Depth 6)); $ws.SendAsync([ArraySegment[byte]]$b, 'Text', $true, $cts.Token).Wait() }",
132
160
  "function Recv() { $buf = New-Object byte[] 65536; $ms = New-Object IO.MemoryStream; do { $r = $ws.ReceiveAsync([ArraySegment[byte]]$buf, $cts.Token).Result; $ms.Write($buf, 0, $r.Count) } while (-not $r.EndOfMessage); [Text.Encoding]::UTF8.GetString($ms.ToArray()) }",
161
+ "function Wait-Reply([int]$n) { while ($true) { $m = Recv; if ($m -match ('\"id\":' + $n + '[,}]')) { return $m } } }",
162
+ ];
163
+ }
164
+
165
+ /** PowerShell that closes the headless browser started by `headlessSessionLines`. */
166
+ const CLOSE_HEADLESS_LINES = [
167
+ "try { Send @{ id = 0; method = 'Browser.close' } } catch {}",
168
+ "if (-not $proc.WaitForExit(10000)) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }",
169
+ ];
170
+
171
+ /**
172
+ * PowerShell that sets the profile's startup preference to "continue where
173
+ * you left off". Chrome deletes browser-session cookies (cookies with no
174
+ * expiry) when it exits normally unless that preference is set, and many
175
+ * sign-ins keep the signed-in state only in such cookies. With it set, the
176
+ * person can simply close the window and the sign-in survives for the
177
+ * harvest, and the next sign-in window reopens where they left off.
178
+ *
179
+ * The preference is protected against edits to the profile's files, so the
180
+ * script sets it the way the settings page does: it opens the profile
181
+ * headless, loads the settings page, and calls the settings API there. When
182
+ * the profile's preference files already show the value, Chrome is not
183
+ * started. The script prints `{"changed":bool,"value":n}`.
184
+ */
185
+ export function keepSessionScript(): string {
186
+ return [
187
+ "param([string]$Chrome, [string]$Profile, [string]$Pattern, [int]$StartupSeconds = 30)",
188
+ "$ErrorActionPreference = 'Stop'",
189
+ ...REFUSE_OPEN_PROFILE_LINES,
190
+ "foreach ($name in @('Secure Preferences', 'Preferences')) { $prefsFile = Join-Path (Join-Path $Profile 'Default') $name; if (Test-Path $prefsFile) { try { $prefs = Get-Content -Raw $prefsFile | ConvertFrom-Json; if ($prefs.session.restore_on_startup -eq 1) { [Console]::Out.Write('{\"changed\":false,\"value\":1}'); exit 0 } } catch {} } }",
191
+ ...headlessSessionLines([]),
192
+ "Send @{ id = 1; method = 'Target.createTarget'; params = @{ url = 'chrome://settings/onStartup' } }",
193
+ "$targetId = (ConvertFrom-Json (Wait-Reply 1)).result.targetId",
194
+ "Send @{ id = 2; method = 'Target.attachToTarget'; params = @{ targetId = $targetId; flatten = $true } }",
195
+ "$sessionId = (ConvertFrom-Json (Wait-Reply 2)).result.sessionId",
196
+ "$expr = \"new Promise((resolve) => { const started = Date.now(); const attempt = () => { const api = globalThis.chrome && chrome.settingsPrivate; if (api) { api.setPref('session.restore_on_startup', 1, '', () => api.getPref('session.restore_on_startup', (pref) => resolve(JSON.stringify({ changed: true, value: pref ? pref.value : null })))); } else if (Date.now() - started > 10000) { resolve(JSON.stringify({ changed: false, value: null })); } else { setTimeout(attempt, 100); } }; attempt(); })\"",
197
+ "Send @{ id = 3; sessionId = $sessionId; method = 'Runtime.evaluate'; params = @{ expression = $expr; awaitPromise = $true; returnByValue = $true } }",
198
+ "$value = (ConvertFrom-Json (Wait-Reply 3)).result.result.value",
199
+ "Start-Sleep -Milliseconds 300",
200
+ ...CLOSE_HEADLESS_LINES,
201
+ "[Console]::Out.Write($value)",
202
+ ].join("\r\n");
203
+ }
204
+
205
+ /**
206
+ * PowerShell that does the whole read on the Windows side: start Chrome
207
+ * headless on the profile with its last session restored, read every cookie
208
+ * with `Storage.getCookies`, list the open pages with `Target.getTargets`,
209
+ * write both raw responses to stdout as one JSON object, and close the
210
+ * browser. WSL never touches the profile folder, which it may not be able to
211
+ * list. Every wait carries a deadline.
212
+ *
213
+ * Restoring the session keeps browser-session cookies that a forced stop left
214
+ * on disk, and it reopens the tabs the person had open, so the harvest can
215
+ * report the last page they reached on the sign-in site. That page's URL may
216
+ * carry a per-session token that cookies alone do not replace. The page list
217
+ * is polled for up to `$PageSeconds` until a page on `$Domain` appears.
218
+ */
219
+ export function harvestScript(): string {
220
+ return [
221
+ "param([string]$Chrome, [string]$Profile, [string]$Pattern, [string]$Domain = '', [int]$StartupSeconds = 30, [int]$PageSeconds = 5)",
222
+ "$ErrorActionPreference = 'Stop'",
223
+ ...headlessSessionLines(["--restore-last-session"]),
224
+ "function Test-SiteUrl([string]$u) { if (-not $Domain) { return $false }; try { $h = ([Uri]$u).Host.ToLowerInvariant() } catch { return $false }; return ($h -eq $Domain -or $h.EndsWith('.' + $Domain)) }",
133
225
  "Send @{ id = 1; method = 'Storage.getCookies' }",
134
- "$out = $null",
135
- "while ($null -eq $out) { $m = Recv; if ($m -match '\"id\":1[,}]') { $out = $m } }",
136
- "[Console]::Out.Write($out)",
137
- "try { Send @{ id = 2; method = 'Browser.close' } } catch {}",
138
- "if (-not $proc.WaitForExit(10000)) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }",
226
+ "$cookiesReply = Wait-Reply 1",
227
+ "$pageDeadline = (Get-Date).AddSeconds($PageSeconds)",
228
+ "$n = 1",
229
+ "while ($true) { $n++; Send @{ id = $n; method = 'Target.getTargets' }; $targetsReply = Wait-Reply $n; $sitePages = @((ConvertFrom-Json $targetsReply).result.targetInfos | Where-Object { $_.type -eq 'page' -and (Test-SiteUrl $_.url) }); if ($sitePages.Count -gt 0 -or (Get-Date) -gt $pageDeadline) { break }; Start-Sleep -Milliseconds 250 }",
230
+ "[Console]::Out.Write('{\"cookies\":' + $cookiesReply + ',\"targets\":' + $targetsReply + '}')",
231
+ ...CLOSE_HEADLESS_LINES,
139
232
  ].join("\r\n");
140
233
  }
141
234
 
235
+ /** Shape `Target.getTargets` returns; extra fields are ignored. */
236
+ interface CdpTargetInfo {
237
+ type: string;
238
+ url: string;
239
+ }
240
+
142
241
  /**
143
- * Reopen the Windows profile headless, read its cookies for `domain`, and
144
- * close it. Returns the cookies in the store's shape.
242
+ * URLs of the open pages on the sign-in site, in the order Chrome lists them,
243
+ * without duplicates.
145
244
  */
146
- export function harvestWindowsChromeCookies(input: {
147
- chromeWindows: string;
148
- profileWindows: string;
149
- domain: string;
150
- startupTimeoutSeconds?: number;
151
- }): Cookie[] {
245
+ export function sitePages(targets: CdpTargetInfo[], domain: string): string[] {
246
+ const pages: string[] = [];
247
+ for (const target of targets) {
248
+ if (target.type !== "page") continue;
249
+ let host: string;
250
+ try {
251
+ host = new URL(target.url).hostname;
252
+ } catch {
253
+ continue;
254
+ }
255
+ if (host && cookieInDomain(host, domain) && !pages.includes(target.url)) pages.push(target.url);
256
+ }
257
+ return pages;
258
+ }
259
+
260
+ export interface WindowsChromeHarvest {
261
+ /** The sign-in site's cookies, browser-session cookies included. */
262
+ cookies: Cookie[];
263
+ /** Open pages on the sign-in site; the first is the one to resume from. */
264
+ pages: string[];
265
+ }
266
+
267
+ /** Parse the harvest script's stdout. */
268
+ export function parseHarvestOutput(stdout: string, domain: string): WindowsChromeHarvest {
269
+ const parsed = JSON.parse(stdout) as {
270
+ cookies?: { result?: { cookies?: CdpCookie[] }; error?: { message?: string } };
271
+ targets?: { result?: { targetInfos?: CdpTargetInfo[] } };
272
+ };
273
+ const cookieList = parsed.cookies?.result?.cookies;
274
+ if (!cookieList)
275
+ throw new Error(
276
+ `Windows Chrome returned no cookie list: ${parsed.cookies?.error?.message ?? "unknown response"}`,
277
+ );
278
+ return {
279
+ cookies: cookiesForDomain(cookieList, domain),
280
+ pages: sitePages(parsed.targets?.result?.targetInfos ?? [], domain),
281
+ };
282
+ }
283
+
284
+ /** Write a PowerShell script to a temp file and run it with `-File`. */
285
+ function runPowerShellScript(
286
+ name: string,
287
+ script: string,
288
+ args: string[],
289
+ timeoutMs: number,
290
+ ): { status: number | null; stdout: string; stderr: string; error?: Error } {
152
291
  const scriptDir = join(tmpdir(), "harnery-windows-chrome");
153
292
  mkdirSync(scriptDir, { recursive: true });
154
- const scriptLinux = join(scriptDir, `harvest-cookies-${process.pid}.ps1`);
155
- writeFileSync(scriptLinux, harvestScript(), { mode: 0o600 });
293
+ const scriptLinux = join(scriptDir, `${name}-${process.pid}.ps1`);
294
+ writeFileSync(scriptLinux, script, { mode: 0o600 });
156
295
  try {
157
296
  const scriptWindows = spawnSync("wslpath", ["-w", scriptLinux], {
158
297
  encoding: "utf8",
159
298
  }).stdout.trim();
160
299
  const result = spawnSync(
161
300
  "powershell.exe",
162
- [
163
- "-NoProfile",
164
- "-ExecutionPolicy",
165
- "Bypass",
166
- "-File",
167
- scriptWindows,
168
- "-Chrome",
169
- input.chromeWindows,
170
- "-Profile",
171
- input.profileWindows,
172
- "-StartupSeconds",
173
- String(input.startupTimeoutSeconds ?? 30),
174
- ],
175
- { encoding: "utf8", timeout: 90_000, maxBuffer: 64 * 1024 * 1024 },
301
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptWindows, ...args],
302
+ { encoding: "utf8", timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 },
176
303
  );
177
- if (result.status !== 0) {
178
- throw new Error(
179
- `Reading cookies from Windows Chrome failed: ${(result.stderr || result.stdout || "no output").trim().slice(0, 400)}`,
180
- );
181
- }
182
- const parsed = JSON.parse(result.stdout) as {
183
- result?: { cookies?: CdpCookie[] };
184
- error?: { message?: string };
304
+ return {
305
+ status: result.status,
306
+ stdout: result.stdout ?? "",
307
+ stderr: result.stderr ?? "",
308
+ ...(result.error ? { error: result.error } : {}),
185
309
  };
186
- if (!parsed.result?.cookies)
187
- throw new Error(
188
- `Windows Chrome returned no cookie list: ${parsed.error?.message ?? "unknown response"}`,
189
- );
190
- return cookiesForDomain(parsed.result.cookies, input.domain);
191
310
  } finally {
192
311
  rmSync(scriptLinux, { force: true });
193
312
  }
194
313
  }
195
314
 
196
315
  /**
197
- * Close any Chrome still running on `profileWindows`, for when the person ends
198
- * the sign-in from the terminal while the window is open. The profile must be
199
- * closed before its cookies can be read. Closing the window first lets Chrome
200
- * write recent cookies to disk; a forced stop is the fallback after 15 s.
316
+ * Reopen the Windows profile headless with its last session restored, read
317
+ * its cookies for `domain` and its open pages on that site, and close it.
318
+ * The profile must already be closed; see `stopWindowsChromeProfile`.
319
+ */
320
+ export function harvestWindowsChromeCookies(input: {
321
+ chromeWindows: string;
322
+ profileWindows: string;
323
+ domain: string;
324
+ startupTimeoutSeconds?: number;
325
+ pageTimeoutSeconds?: number;
326
+ }): WindowsChromeHarvest {
327
+ const result = runPowerShellScript(
328
+ "harvest-cookies",
329
+ harvestScript(),
330
+ [
331
+ "-Chrome",
332
+ input.chromeWindows,
333
+ "-Profile",
334
+ input.profileWindows,
335
+ "-Pattern",
336
+ profileCommandLinePattern(input.profileWindows),
337
+ "-Domain",
338
+ input.domain,
339
+ "-StartupSeconds",
340
+ String(input.startupTimeoutSeconds ?? 30),
341
+ "-PageSeconds",
342
+ String(input.pageTimeoutSeconds ?? 5),
343
+ ],
344
+ 90_000,
345
+ );
346
+ if (result.status !== 0) {
347
+ throw new Error(
348
+ `Reading cookies from Windows Chrome failed: ${(result.stderr || result.stdout || result.error?.message || "no output").trim().slice(0, 400)}`,
349
+ );
350
+ }
351
+ return parseHarvestOutput(result.stdout, input.domain);
352
+ }
353
+
354
+ export interface WindowsChromeKeepSession {
355
+ /** True when the preference was already set or is now set. */
356
+ kept: boolean;
357
+ /** True when this call changed the preference. */
358
+ changed: boolean;
359
+ }
360
+
361
+ /**
362
+ * Set the profile to keep browser-session cookies across a normal exit; see
363
+ * `keepSessionScript`. Throws when Chrome is already open on the profile,
364
+ * since a second sign-in window would hand off to it. Any other failure
365
+ * returns `kept: false` so the caller can warn and continue.
201
366
  */
202
- export function stopWindowsChromeProfile(profileWindows: string): void {
203
- const escaped = profileWindows.replace(/'/g, "''");
204
- const select = `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -like '*${escaped}*' }`;
205
- spawnSync(
206
- "powershell.exe",
367
+ export function keepWindowsChromeSession(input: {
368
+ chromeWindows: string;
369
+ profileWindows: string;
370
+ startupTimeoutSeconds?: number;
371
+ }): WindowsChromeKeepSession {
372
+ const result = runPowerShellScript(
373
+ "keep-session",
374
+ keepSessionScript(),
207
375
  [
208
- "-NoProfile",
209
- "-Command",
210
- [
211
- `$ids = @(${select} | ForEach-Object { $_.ProcessId })`,
212
- "foreach ($id in $ids) { $p = Get-Process -Id $id -ErrorAction SilentlyContinue; if ($p -and $p.MainWindowHandle -ne 0) { [void]$p.CloseMainWindow() } }",
213
- "$deadline = (Get-Date).AddSeconds(15)",
214
- `while ((Get-Date) -lt $deadline -and @(${select}).Count -gt 0) { Start-Sleep -Milliseconds 250 }`,
215
- `${select} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
216
- ].join("; "),
376
+ "-Chrome",
377
+ input.chromeWindows,
378
+ "-Profile",
379
+ input.profileWindows,
380
+ "-Pattern",
381
+ profileCommandLinePattern(input.profileWindows),
382
+ "-StartupSeconds",
383
+ String(input.startupTimeoutSeconds ?? 30),
217
384
  ],
218
- { encoding: "utf8", timeout: 45_000 },
385
+ 90_000,
386
+ );
387
+ const detail = (result.stderr || result.stdout || result.error?.message || "").trim();
388
+ if (result.status !== 0) {
389
+ if (detail.includes("Chrome is still running on the sign-in profile"))
390
+ throw new Error(
391
+ `Chrome is already open on the sign-in profile ${input.profileWindows}. Close that window and run the sign-in again.`,
392
+ );
393
+ return { kept: false, changed: false };
394
+ }
395
+ try {
396
+ const parsed = JSON.parse(result.stdout) as { changed?: boolean; value?: unknown };
397
+ return { kept: parsed.value === 1, changed: parsed.changed === true };
398
+ } catch {
399
+ return { kept: false, changed: false };
400
+ }
401
+ }
402
+
403
+ /**
404
+ * PowerShell that closes every Chrome whose command line matches `$Pattern`. It asks each window to
405
+ * close, repeating so a profile with several windows closes them all, which
406
+ * lets Chrome write its cookies and session to disk. Whatever is still
407
+ * running after `$CloseSeconds` is stopped by force. The script prints
408
+ * `{found, forced, remaining}` as JSON; `remaining` lists process ids that
409
+ * survived even the forced stop.
410
+ */
411
+ export function stopScript(): string {
412
+ return [
413
+ "param([string]$Pattern, [int]$CloseSeconds = 15)",
414
+ "$ErrorActionPreference = 'Stop'",
415
+ PROFILE_PROCESS_FUNCTION,
416
+ "$found = (Get-ProfileChrome).Count",
417
+ "$deadline = (Get-Date).AddSeconds($CloseSeconds)",
418
+ "while ($true) { $procs = Get-ProfileChrome; if ($procs.Count -eq 0 -or (Get-Date) -gt $deadline) { break }; foreach ($c in $procs) { try { $p = Get-Process -Id $c.ProcessId -ErrorAction SilentlyContinue; if ($p -and $p.MainWindowHandle -ne 0) { [void]$p.CloseMainWindow() } } catch {} }; Start-Sleep -Milliseconds 500 }",
419
+ "$forced = 0",
420
+ "foreach ($c in $(Get-ProfileChrome)) { Stop-Process -Id $c.ProcessId -Force -ErrorAction SilentlyContinue; $forced++ }",
421
+ "if ($forced -gt 0) { $killDeadline = (Get-Date).AddSeconds(10); while ((Get-ProfileChrome).Count -gt 0 -and (Get-Date) -lt $killDeadline) { Start-Sleep -Milliseconds 250 } }",
422
+ "$remaining = @(Get-ProfileChrome | ForEach-Object { [int]$_.ProcessId })",
423
+ "[Console]::Out.Write((@{ found = $found; forced = $forced; remaining = $remaining } | ConvertTo-Json -Compress))",
424
+ ].join("\r\n");
425
+ }
426
+
427
+ export interface WindowsChromeStop {
428
+ /** Chrome processes running on the profile when the stop began. */
429
+ found: number;
430
+ /** Processes that ignored the close request and were stopped by force. */
431
+ forced: number;
432
+ }
433
+
434
+ /** Parse the stop script's stdout; throws when a process survived. */
435
+ export function parseStopOutput(stdout: string, profileWindows: string): WindowsChromeStop {
436
+ const parsed = JSON.parse(stdout) as {
437
+ found?: number;
438
+ forced?: number;
439
+ remaining?: number | number[] | null;
440
+ };
441
+ const remaining =
442
+ parsed.remaining === undefined || parsed.remaining === null
443
+ ? []
444
+ : Array.isArray(parsed.remaining)
445
+ ? parsed.remaining
446
+ : [parsed.remaining];
447
+ if (remaining.length > 0) {
448
+ throw new Error(
449
+ `Chrome is still running on ${profileWindows} (process ${remaining.join(", ")}) after a forced stop; its cookies cannot be read until it exits.`,
450
+ );
451
+ }
452
+ return { found: Number(parsed.found ?? 0), forced: Number(parsed.forced ?? 0) };
453
+ }
454
+
455
+ /**
456
+ * Close every Chrome still running on `profileWindows`, for when the person
457
+ * ends the sign-in from the terminal while the window is open. The profile
458
+ * must be closed before its cookies can be read. Closing the windows lets
459
+ * Chrome write recent cookies to disk; a forced stop is the fallback after
460
+ * `closeSeconds`, and it can lose cookies Chrome had not yet written. Throws
461
+ * when the stop cannot be confirmed, so the harvest never runs against a
462
+ * profile that is still open.
463
+ */
464
+ export function stopWindowsChromeProfile(
465
+ profileWindows: string,
466
+ closeSeconds = 15,
467
+ ): WindowsChromeStop {
468
+ const result = runPowerShellScript(
469
+ "stop-profile",
470
+ stopScript(),
471
+ ["-Pattern", profileCommandLinePattern(profileWindows), "-CloseSeconds", String(closeSeconds)],
472
+ (closeSeconds + 60) * 1000,
219
473
  );
474
+ if (result.status !== 0) {
475
+ throw new Error(
476
+ `Closing Chrome on ${profileWindows} failed: ${(result.stderr || result.stdout || result.error?.message || "no output").trim().slice(0, 400)}`,
477
+ );
478
+ }
479
+ return parseStopOutput(result.stdout, profileWindows);
220
480
  }
221
481
 
222
482
  /** Windows form of a `/mnt/<drive>/...` path. */
@@ -363,6 +363,52 @@ export function slugify(heading: string): string {
363
363
  .replace(/\s/g, "-");
364
364
  }
365
365
 
366
+ /** Keep literal code text out of the heading's inline-markup replacements. */
367
+ function shieldHeadingCode(text: string): { text: string; restore: (text: string) => string } {
368
+ let marker = "\u0000";
369
+ while (text.includes(marker)) marker += "\u0000";
370
+ const spans: string[] = [];
371
+ const tokens = /<[^>]+>|`+/g;
372
+ let masked = "";
373
+ let copiedThrough = 0;
374
+
375
+ for (let opening = tokens.exec(text); opening; opening = tokens.exec(text)) {
376
+ // An HTML tag starts before its attribute values; their backticks are not
377
+ // code delimiters. Escaped opening backticks are literal punctuation too.
378
+ if (!opening[0].startsWith("`")) continue;
379
+ let backslashes = 0;
380
+ for (let i = opening.index - 1; i >= 0 && text[i] === "\\"; i--) backslashes++;
381
+ if (backslashes % 2 !== 0) continue;
382
+
383
+ const closingRuns = /`+/g;
384
+ closingRuns.lastIndex = opening.index + opening[0].length;
385
+ let closing = closingRuns.exec(text);
386
+ while (closing && closing[0].length !== opening[0].length) {
387
+ closing = closingRuns.exec(text);
388
+ }
389
+ if (!closing) continue;
390
+
391
+ let code = text.slice(opening.index + opening[0].length, closing.index);
392
+ // Code spans remove one padding space at each end, except all-space spans.
393
+ if (code.startsWith(" ") && code.endsWith(" ") && /[^ ]/.test(code)) {
394
+ code = code.slice(1, -1);
395
+ }
396
+ masked += text.slice(copiedThrough, opening.index) + marker + spans.length + marker;
397
+ spans.push(code);
398
+ copiedThrough = closing.index + closing[0].length;
399
+ tokens.lastIndex = copiedThrough;
400
+ }
401
+
402
+ return {
403
+ text: masked + text.slice(copiedThrough),
404
+ restore: (filtered) =>
405
+ filtered.replace(
406
+ new RegExp(`${marker}(\\d+)${marker}`, "g"),
407
+ (_, index: string) => spans[Number(index)]!,
408
+ ),
409
+ };
410
+ }
411
+
366
412
  /**
367
413
  * Every fragment a reader can legitimately target in one document: Markdown
368
414
  * heading slugs (ATX and Setext), explicit `{#custom-id}` suffixes, and HTML
@@ -388,7 +434,8 @@ export function collectAnchors(content: string): Set<string> {
388
434
  // so only word-boundary underscores are markup. `_` counts as a word
389
435
  // character in JS regex, so \b sits exactly at the space-to-underscore
390
436
  // seam these delimiters occupy.
391
- text = text
437
+ const code = shieldHeadingCode(text);
438
+ text = code.text
392
439
  // Backslash escapes render as the bare character, so `snake\_case`
393
440
  // must anchor exactly like `snake_case`. Unescape before the emphasis
394
441
  // strip or the escaped underscore gains a phantom word boundary.
@@ -397,7 +444,7 @@ export function collectAnchors(content: string): Set<string> {
397
444
  .replace(/[*~`]/g, "")
398
445
  .replace(/\b_+|_+\b/g, "")
399
446
  .replace(/<[^>]+>/g, "");
400
- const base = slugify(text);
447
+ const base = slugify(code.restore(text));
401
448
  if (!base) return;
402
449
  const n = seen.get(base) ?? 0;
403
450
  seen.set(base, n + 1);