harnery 0.41.0 → 0.42.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 (212) hide show
  1. package/dist/commander.js +3 -0
  2. package/dist/commands/agents.d.ts.map +1 -1
  3. package/dist/commands/agents.js +9 -1
  4. package/dist/commands/artifacts.d.ts.map +1 -1
  5. package/dist/commands/artifacts.js +113 -12
  6. package/dist/commands/browse.d.ts +164 -0
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +263 -51
  9. package/dist/commands/claude-desktop.d.ts.map +1 -1
  10. package/dist/commands/claude-desktop.js +52 -0
  11. package/dist/commands/disk.d.ts +6 -0
  12. package/dist/commands/disk.d.ts.map +1 -0
  13. package/dist/commands/disk.js +80 -0
  14. package/dist/commands/doctor.d.ts +8 -0
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +48 -1
  17. package/dist/commands/files.d.ts +16 -0
  18. package/dist/commands/files.d.ts.map +1 -1
  19. package/dist/commands/files.js +54 -2
  20. package/dist/commands/init.d.ts +2 -2
  21. package/dist/commands/init.d.ts.map +1 -1
  22. package/dist/commands/init.js +43 -9
  23. package/dist/commands/rm.d.ts +4 -0
  24. package/dist/commands/rm.d.ts.map +1 -0
  25. package/dist/commands/rm.js +87 -0
  26. package/dist/commands/servers.d.ts +7 -0
  27. package/dist/commands/servers.d.ts.map +1 -0
  28. package/dist/commands/servers.js +394 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +82 -2
  31. package/dist/core/adapters/profiles.d.ts +9 -0
  32. package/dist/core/adapters/profiles.d.ts.map +1 -1
  33. package/dist/core/adapters/profiles.js +22 -1
  34. package/dist/core/adapters/types.d.ts +9 -0
  35. package/dist/core/adapters/types.d.ts.map +1 -1
  36. package/dist/core/agents/cli.js +13 -6
  37. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/session-context.js +2 -0
  39. package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
  40. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  41. package/dist/core/agents/state/live-coordination-view.js +60 -0
  42. package/dist/core/artifacts/delivery-card.js +9 -2
  43. package/dist/core/artifacts/index.d.ts +140 -4
  44. package/dist/core/artifacts/index.d.ts.map +1 -1
  45. package/dist/core/artifacts/index.js +522 -49
  46. package/dist/core/config.d.ts +68 -11
  47. package/dist/core/config.d.ts.map +1 -1
  48. package/dist/core/config.js +128 -3
  49. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  50. package/dist/core/diagnostics/bundle.js +2 -2
  51. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  52. package/dist/core/events/v3/producers/recorder.js +16 -0
  53. package/dist/core/events/v3/projection.js +9 -1
  54. package/dist/core/hooks/adapter/events.d.ts +17 -0
  55. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  56. package/dist/core/hooks/adapter/events.js +5 -0
  57. package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
  58. package/dist/core/hooks/adapter/wiring.d.ts +37 -7
  59. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  60. package/dist/core/hooks/adapter/wiring.js +62 -4
  61. package/dist/core/hooks/cli.js +45 -6
  62. package/dist/core/hooks/run-markers.d.ts +37 -0
  63. package/dist/core/hooks/run-markers.d.ts.map +1 -0
  64. package/dist/core/hooks/run-markers.js +109 -0
  65. package/dist/core/servers/index.d.ts +211 -0
  66. package/dist/core/servers/index.d.ts.map +1 -0
  67. package/dist/core/servers/index.js +560 -0
  68. package/dist/core/servers/net.d.ts +58 -0
  69. package/dist/core/servers/net.d.ts.map +1 -0
  70. package/dist/core/servers/net.js +270 -0
  71. package/dist/core/servers/tunnels.d.ts +14 -0
  72. package/dist/core/servers/tunnels.d.ts.map +1 -0
  73. package/dist/core/servers/tunnels.js +33 -0
  74. package/dist/core/storage/builtins.js +1 -0
  75. package/dist/core/workflow/engine.d.ts.map +1 -1
  76. package/dist/core/workflow/engine.js +30 -1
  77. package/dist/core/workflow/sandbox-projection.d.ts +13 -2
  78. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
  79. package/dist/core/workflow/sandbox-projection.js +23 -0
  80. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  81. package/dist/core/workflow/spawn-claude.js +6 -1
  82. package/dist/core/workflow/spawn-codex.d.ts +2 -0
  83. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  84. package/dist/core/workflow/spawn-codex.js +20 -1
  85. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  86. package/dist/core/workflow/spawn-cursor.js +6 -1
  87. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  88. package/dist/core/workflow/spawn-opencode.js +4 -1
  89. package/dist/core/workflow/types.d.ts +47 -2
  90. package/dist/core/workflow/types.d.ts.map +1 -1
  91. package/dist/core/workflow/worker-access.d.ts +92 -0
  92. package/dist/core/workflow/worker-access.d.ts.map +1 -0
  93. package/dist/core/workflow/worker-access.js +450 -0
  94. package/dist/lib/browser/batch-steps.d.ts +18 -0
  95. package/dist/lib/browser/batch-steps.d.ts.map +1 -0
  96. package/dist/lib/browser/batch-steps.js +82 -0
  97. package/dist/lib/browser/collect/collector.d.ts +99 -0
  98. package/dist/lib/browser/collect/collector.d.ts.map +1 -0
  99. package/dist/lib/browser/collect/collector.js +202 -0
  100. package/dist/lib/browser/collect/fields.d.ts +31 -0
  101. package/dist/lib/browser/collect/fields.d.ts.map +1 -0
  102. package/dist/lib/browser/collect/fields.js +75 -0
  103. package/dist/lib/browser/collect/index.d.ts +6 -0
  104. package/dist/lib/browser/collect/index.d.ts.map +1 -0
  105. package/dist/lib/browser/collect/index.js +5 -0
  106. package/dist/lib/browser/collect/page-driver.d.ts +14 -0
  107. package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
  108. package/dist/lib/browser/collect/page-driver.js +306 -0
  109. package/dist/lib/browser/collect/presets.d.ts +52 -0
  110. package/dist/lib/browser/collect/presets.d.ts.map +1 -0
  111. package/dist/lib/browser/collect/presets.js +176 -0
  112. package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
  113. package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
  114. package/dist/lib/browser/collect/scroll-plan.js +118 -0
  115. package/dist/lib/browser/index.d.ts +1 -1
  116. package/dist/lib/browser/index.d.ts.map +1 -1
  117. package/dist/lib/browser/page-review-judge.d.ts +2 -0
  118. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  119. package/dist/lib/browser/page-review-judge.js +13 -0
  120. package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
  121. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  122. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  123. package/dist/lib/browser/qa-run.js +1 -0
  124. package/dist/lib/browser/windows-chrome.d.ts +59 -0
  125. package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
  126. package/dist/lib/browser/windows-chrome.js +179 -0
  127. package/dist/lib/claude-desktop-share.d.ts +136 -0
  128. package/dist/lib/claude-desktop-share.d.ts.map +1 -0
  129. package/dist/lib/claude-desktop-share.js +394 -0
  130. package/dist/lib/disk-usage.d.ts +50 -0
  131. package/dist/lib/disk-usage.d.ts.map +1 -0
  132. package/dist/lib/disk-usage.js +303 -0
  133. package/dist/lib/docs-lint.d.ts +6 -0
  134. package/dist/lib/docs-lint.d.ts.map +1 -1
  135. package/dist/lib/docs-lint.js +8 -3
  136. package/dist/lib/guarded-remove.d.ts +34 -0
  137. package/dist/lib/guarded-remove.d.ts.map +1 -0
  138. package/dist/lib/guarded-remove.js +310 -0
  139. package/dist/lib/instructions/templates.js +8 -8
  140. package/dist/lib/tunnel/error-page.d.ts +1 -1
  141. package/dist/lib/tunnel/error-page.d.ts.map +1 -1
  142. package/dist/lib/tunnel/error-page.js +19 -8
  143. package/dist/lib/tunnel/gate.js +20 -1
  144. package/dist/lib/tunnel/path-scope.d.ts +22 -0
  145. package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
  146. package/dist/lib/tunnel/path-scope.js +62 -0
  147. package/dist/lib/tunnel/state.d.ts +5 -0
  148. package/dist/lib/tunnel/state.d.ts.map +1 -1
  149. package/dist/lib/tunnel/state.js +17 -3
  150. package/package.json +6 -1
  151. package/schemas/config.schema.json +91 -0
  152. package/src/commander.ts +19 -0
  153. package/src/commands/agents.ts +14 -0
  154. package/src/commands/artifacts.ts +171 -19
  155. package/src/commands/browse.ts +400 -57
  156. package/src/commands/claude-desktop.ts +61 -0
  157. package/src/commands/disk.ts +114 -0
  158. package/src/commands/doctor.ts +52 -1
  159. package/src/commands/files.ts +93 -2
  160. package/src/commands/init.ts +55 -5
  161. package/src/commands/rm.ts +115 -0
  162. package/src/commands/servers.ts +506 -0
  163. package/src/commands/tunnel.ts +102 -2
  164. package/src/core/adapters/profiles.ts +22 -1
  165. package/src/core/adapters/types.ts +10 -0
  166. package/src/core/agents/cli.ts +14 -6
  167. package/src/core/agents/render/session-context.ts +1 -0
  168. package/src/core/agents/state/live-coordination-view.ts +73 -0
  169. package/src/core/artifacts/delivery-card.ts +10 -2
  170. package/src/core/artifacts/index.ts +697 -44
  171. package/src/core/config.ts +183 -4
  172. package/src/core/diagnostics/bundle.ts +2 -1
  173. package/src/core/events/v3/producers/recorder.ts +16 -0
  174. package/src/core/events/v3/projection.ts +11 -1
  175. package/src/core/hooks/adapter/events.ts +19 -0
  176. package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
  177. package/src/core/hooks/adapter/wiring.ts +116 -8
  178. package/src/core/hooks/cli.ts +47 -6
  179. package/src/core/hooks/run-markers.ts +132 -0
  180. package/src/core/servers/index.ts +712 -0
  181. package/src/core/servers/net.ts +283 -0
  182. package/src/core/servers/tunnels.ts +39 -0
  183. package/src/core/storage/builtins.ts +1 -0
  184. package/src/core/workflow/engine.ts +38 -1
  185. package/src/core/workflow/sandbox-projection.ts +38 -2
  186. package/src/core/workflow/spawn-claude.ts +5 -1
  187. package/src/core/workflow/spawn-codex.ts +18 -1
  188. package/src/core/workflow/spawn-cursor.ts +5 -1
  189. package/src/core/workflow/spawn-opencode.ts +4 -1
  190. package/src/core/workflow/types.ts +62 -2
  191. package/src/core/workflow/worker-access.ts +525 -0
  192. package/src/lib/browser/batch-steps.ts +79 -0
  193. package/src/lib/browser/collect/collector.ts +306 -0
  194. package/src/lib/browser/collect/fields.ts +87 -0
  195. package/src/lib/browser/collect/index.ts +47 -0
  196. package/src/lib/browser/collect/page-driver.ts +327 -0
  197. package/src/lib/browser/collect/presets.ts +237 -0
  198. package/src/lib/browser/collect/scroll-plan.ts +179 -0
  199. package/src/lib/browser/index.ts +1 -0
  200. package/src/lib/browser/page-review-judge.ts +16 -0
  201. package/src/lib/browser/qa-run-contracts.ts +11 -0
  202. package/src/lib/browser/qa-run.ts +1 -0
  203. package/src/lib/browser/windows-chrome.ts +227 -0
  204. package/src/lib/claude-desktop-share.ts +511 -0
  205. package/src/lib/disk-usage.ts +347 -0
  206. package/src/lib/docs-lint.ts +8 -3
  207. package/src/lib/guarded-remove.ts +376 -0
  208. package/src/lib/instructions/templates.ts +8 -8
  209. package/src/lib/tunnel/error-page.ts +25 -9
  210. package/src/lib/tunnel/gate.ts +22 -1
  211. package/src/lib/tunnel/path-scope.ts +73 -0
  212. package/src/lib/tunnel/state.ts +20 -3
@@ -7,6 +7,7 @@ import { chromium } from "playwright";
7
7
  import { PNG } from "pngjs";
8
8
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
9
9
  import { resolveBinName } from "../core/config.ts";
10
+ import { splitBatchSteps } from "../lib/browser/batch-steps.ts";
10
11
  import {
11
12
  CAPTURE_FIDELITY_MISMATCH_THRESHOLD,
12
13
  compareBand,
@@ -15,6 +16,22 @@ import {
15
16
  type FidelityProbe,
16
17
  pngDimensions,
17
18
  } from "../lib/browser/capture-fidelity.ts";
19
+ import {
20
+ type CollectLimits,
21
+ createPageDriver,
22
+ createRng,
23
+ DEFAULT_COLLECT_LIMITS,
24
+ PACING_LIMITS,
25
+ parseProbability,
26
+ parseRange,
27
+ presetNames,
28
+ type ResolvedCollectSpec,
29
+ randomSeed,
30
+ resolveCollectSpec,
31
+ runCollect,
32
+ type ScrollPacing,
33
+ scrollPacingFromEnv,
34
+ } from "../lib/browser/collect/index.ts";
18
35
  import {
19
36
  type AssertResult,
20
37
  Browser,
@@ -50,6 +67,7 @@ import {
50
67
  writeNetscapeCookieFile,
51
68
  wslHeadedLaunchArgs,
52
69
  } from "../lib/browser/index.ts";
70
+ import { isWSL } from "../lib/browser/launch-args.ts";
53
71
  import {
54
72
  allocateTileBudget,
55
73
  validatePageReviewAllocation,
@@ -113,7 +131,16 @@ import {
113
131
  type SaveBaselineResult,
114
132
  saveBaseline,
115
133
  } from "../lib/browser/visual-diff.ts";
116
- import { applyExtraCookies, CookieJar } from "../lib/cookies/index.ts";
134
+ import {
135
+ harvestDomain,
136
+ harvestWindowsChromeCookies,
137
+ stopWindowsChromeProfile,
138
+ windowsChromePath,
139
+ windowsLocalAppData,
140
+ windowsPlainProfile,
141
+ wslPathToWindows,
142
+ } from "../lib/browser/windows-chrome.ts";
143
+ import { applyExtraCookies, CookieJar, mergeCookies } from "../lib/cookies/index.ts";
117
144
  import { commandPaceGate } from "../lib/pace/index.ts";
118
145
 
119
146
  /**
@@ -167,9 +194,28 @@ interface BrowseOpts {
167
194
  evaluate?: string;
168
195
  captureEvaluate?: string;
169
196
  batch?: string;
197
+ // Scroll-and-collect mode (--collect [preset])
198
+ collect?: string | boolean;
199
+ collectItem?: string;
200
+ collectKey?: string;
201
+ collectKeyPattern?: string;
202
+ collectField?: string[];
203
+ collectExpand?: string | boolean;
204
+ collectMaxItems?: string;
205
+ collectMaxScrolls?: string;
206
+ collectMaxSeconds?: string;
207
+ collectIdle?: string;
208
+ collectStep?: string;
209
+ collectPause?: string;
210
+ collectReadChance?: string;
211
+ collectReadPause?: string;
212
+ collectBackChance?: string;
213
+ collectSeed?: string;
214
+ collectFormat?: string;
215
+ collectOut?: string;
170
216
  networkHar?: string;
171
217
  login?: boolean;
172
- plain?: boolean;
218
+ attached?: boolean;
173
219
  loginCloseFile?: string;
174
220
  controlFile?: string;
175
221
  headed?: boolean;
@@ -345,8 +391,69 @@ export function registerBrowseCommand(
345
391
  "--batch <steps>",
346
392
  "Run multiple steps in one session, semicolon-separated. Each step is one of: " +
347
393
  "`click <selector>`, `fill <selector=>value>`, `press <key>`, `wait <selector|ms>`, `eval <js>`, `reload`. " +
348
- 'Example: `--batch "click button; wait 1500; reload; wait 3000"`. `reload` preserves sessionStorage + cookies, which is how to repro sessionStorage-restored UI state.',
394
+ 'Example: `--batch "click button; wait 1500; reload; wait 3000"`. `reload` preserves sessionStorage + cookies, which is how to repro sessionStorage-restored UI state. ' +
395
+ "An `eval` step keeps semicolons inside JS quotes and (), [], {}; `\\;` is always a literal semicolon.",
349
396
  )
397
+ .option(
398
+ "--collect [preset]",
399
+ "Scroll an infinite feed like a reader and collect every item that passes through the DOM, " +
400
+ `de-duplicated, as JSON. Presets: ${presetNames().join(", ")} (default generic). ` +
401
+ "Skips the trio, checks and --evaluate; runs after --wait-for and --batch.",
402
+ )
403
+ .option("--collect-item <css>", "Item selector (overrides the preset's).")
404
+ .option(
405
+ "--collect-key <extractor>",
406
+ "Dedupe key extractor, <selector>[@attr] inside the item. Default: the preset key, else the " +
407
+ "first permalink-like link, else a hash of the item text.",
408
+ )
409
+ .option("--collect-key-pattern <regex>", "Regex a link must match to count as a permalink key.")
410
+ .option(
411
+ "--collect-field <name=extractor>",
412
+ "Add or override a field: name=<selector>[@attr]. name[]= collects every match. Empty selector " +
413
+ "or :scope is the item itself; no @attr reads visible text (repeatable).",
414
+ (value: string, previous: string[] = []) => [...previous, value],
415
+ [] as string[],
416
+ )
417
+ .option(
418
+ "--collect-expand [selector]",
419
+ "Click 'show more' buttons inside visible items before harvesting (preset selector by default). " +
420
+ "At most 3 per step and 50 per run; links and action buttons are never clicked.",
421
+ )
422
+ .option("--collect-max-items <n>", "Stop after this many unique items.")
423
+ .option("--collect-max-scrolls <n>", "Stop after this many scroll steps (default 60).", "60")
424
+ .option("--collect-max-seconds <n>", "Stop after this much wall time (default 300).", "300")
425
+ .option(
426
+ "--collect-idle <n>",
427
+ "Stop after this many steps with no new items and no page growth (default 5).",
428
+ "5",
429
+ )
430
+ .option(
431
+ "--collect-step <min-max>",
432
+ "Scroll distance per step as a viewport fraction (default 0.6-1.1; env HARNERY_COLLECT_STEP).",
433
+ )
434
+ .option(
435
+ "--collect-pause <min-max>",
436
+ "Pause after each step in ms (default 1200-3500; env HARNERY_COLLECT_PAUSE_MS).",
437
+ )
438
+ .option(
439
+ "--collect-read-chance <p>",
440
+ "Chance of a longer reading pause per step (default 0.12).",
441
+ )
442
+ .option("--collect-read-pause <min-max>", "Reading pause in ms (default 4000-9000).")
443
+ .option(
444
+ "--collect-back-chance <p>",
445
+ "Chance a step starts with a small scroll back up (default 0.08).",
446
+ )
447
+ .option(
448
+ "--collect-seed <n>",
449
+ "Seed for the scroll schedule; the envelope reports the seed used.",
450
+ )
451
+ .option(
452
+ "--collect-format <format>",
453
+ "json (envelope, default) or jsonl (one item per line).",
454
+ "json",
455
+ )
456
+ .option("--collect-out <path>", "Write the collection to this file instead of stdout.")
350
457
  .option("--network-har <path>", "Record network traffic to a HAR file (finalized on close)")
351
458
  .option(
352
459
  "--viewport <preset|WxH>",
@@ -358,14 +465,18 @@ export function registerBrowseCommand(
358
465
  "Renders theme-aware pages in that scheme without page-specific toggles. " +
359
466
  "Absent = browser default (unchanged behavior).",
360
467
  )
361
- .option("--login", "Headed mode for one-time auth flow (cookies persist in profile)")
362
468
  .option(
363
- "--plain",
364
- "With --login: open the URL in the installed Google Chrome with nothing attached (no DevTools " +
365
- "protocol, no Playwright) on the same persistent profile. Use it for sign-in flows whose " +
366
- "verification step detects automation itself (X does). Sign in, then close the window or " +
367
- "create the --login-close-file; later browse and browse-session runs reuse the profile's cookies. " +
368
- "Not compatible with --control-file, because nothing is attached to drive.",
469
+ "--login",
470
+ "Open the URL in the installed Google Chrome with nothing attached (no DevTools protocol, no " +
471
+ "Playwright) so a person can sign in; under WSL this is the Windows host's Chrome. Sign in, then " +
472
+ "close the window or create the --login-close-file; later browse and browse-session runs reuse " +
473
+ "the cookies. Sites cannot tell this window from the person's own browser.",
474
+ )
475
+ .option(
476
+ "--attached",
477
+ "With --login: open an automation-controlled Chrome that this process and --control-file can drive. " +
478
+ "Sites can detect it and may lock the account, so use it only for agent-driven headed sessions on " +
479
+ "sites you own. Never use it to sign in to a third-party account.",
369
480
  )
370
481
  .option(
371
482
  "--login-close-file <path>",
@@ -373,9 +484,12 @@ export function registerBrowseCommand(
373
484
  )
374
485
  .option(
375
486
  "--control-file <path>",
376
- "With --login, publish an owner-only descriptor for repeated browse-session control",
487
+ "With --login --attached, publish an owner-only descriptor for repeated browse-session control",
488
+ )
489
+ .option(
490
+ "--headed",
491
+ "Automation-controlled headed window for one-off visual checks. Not for signing in; use --login.",
377
492
  )
378
- .option("--headed", "Headed mode for one-off (no auth-flow framing)")
379
493
  .option(
380
494
  "--browser-channel <name>",
381
495
  "Browser to launch: chrome | chrome-beta | msedge | chromium. Headed launches (--login, --headed) " +
@@ -893,7 +1007,8 @@ async function runBrowse(
893
1007
  ? null
894
1008
  : new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-browse" });
895
1009
  if (jar) applyExtraCookies(url, jar, context?.extraCookies);
896
- const headed = opts.login || opts.headed;
1010
+ const loginMode = resolveLoginMode(opts);
1011
+ const headed = loginMode === "attached" || opts.headed;
897
1012
  const viewport = parseViewport(opts.viewport ?? "desktop");
898
1013
  const deviceScaleFactor = parseDeviceScaleFactor(opts.deviceScaleFactor);
899
1014
  if (opts.reviewPackAllocation && !opts.reviewPack) {
@@ -925,12 +1040,6 @@ async function runBrowse(
925
1040
  }
926
1041
  const proxy = opts.proxyFromEnv ? browserProxyFromEnv() : undefined;
927
1042
  const proxyGate = opts.proxyFromEnv ? browserProxyGateFromEnv() : null;
928
- if (opts.loginCloseFile && !opts.login) {
929
- throw new Error("--login-close-file requires --login.");
930
- }
931
- if (opts.controlFile && !opts.login) {
932
- throw new Error("--control-file requires --login.");
933
- }
934
1043
  const loginCloseFile = opts.loginCloseFile ? resolve(opts.loginCloseFile) : null;
935
1044
  if (loginCloseFile && existsSync(loginCloseFile)) {
936
1045
  throw new Error(
@@ -938,14 +1047,10 @@ async function runBrowse(
938
1047
  );
939
1048
  }
940
1049
 
1050
+ const collectPlan = opts.collect ? buildCollectPlan(opts) : null;
1051
+
941
1052
  const pace = commandPaceGate(opts.pace !== false, (message) => emit.log(message, "info"));
942
- if (opts.plain) {
943
- if (!opts.login) throw new Error("--plain requires --login.");
944
- if (opts.controlFile) {
945
- throw new Error(
946
- "--plain cannot take --control-file: nothing is attached to drive the window.",
947
- );
948
- }
1053
+ if (loginMode === "plain") {
949
1054
  const plainExecutable = installedChromePath();
950
1055
  const plainUa = resolveUserAgent({
951
1056
  requested: opts.userAgent,
@@ -953,6 +1058,10 @@ async function runBrowse(
953
1058
  chromeMajor: plainExecutable ? chromeMajorFromExecutable(plainExecutable) : undefined,
954
1059
  });
955
1060
  await pace?.before(url);
1061
+ if (useWindowsPlainLogin()) {
1062
+ await runWindowsPlainLogin(url, opts.store ?? DEFAULT_STORE, loginCloseFile, emit);
1063
+ return;
1064
+ }
956
1065
  await runPlainLogin(url, opts.profile ?? DEFAULT_PROFILE, loginCloseFile, plainUa, emit);
957
1066
  return;
958
1067
  }
@@ -1030,6 +1139,11 @@ async function runBrowse(
1030
1139
  batchResult = await runBatch(browser, opts.batch, Number.parseInt(opts.timeout, 10));
1031
1140
  }
1032
1141
 
1142
+ if (collectPlan) {
1143
+ await runCollectMode(browser, collectPlan);
1144
+ return;
1145
+ }
1146
+
1033
1147
  let evalResult: unknown;
1034
1148
  if (opts.evaluate) {
1035
1149
  evalResult = await browser.evaluate<unknown>(opts.evaluate);
@@ -1484,13 +1598,13 @@ async function runBrowse(
1484
1598
  const closeWaits: Promise<void>[] = [];
1485
1599
  if (loginCloseFile) {
1486
1600
  emit.log(
1487
- `[--login] Headed Chromium is open. Drive the visible window now. Create ${loginCloseFile} to close cleanly.`,
1601
+ `[--login --attached] Automation-controlled Chrome is open. Drive the visible window now. Create ${loginCloseFile} to close cleanly.`,
1488
1602
  "info",
1489
1603
  );
1490
1604
  closeWaits.push(waitForLoginCloseFile(loginCloseFile, lifecycleAbort.signal));
1491
1605
  } else {
1492
1606
  emit.log(
1493
- "[--login] Headed Chromium is open. Walk through your auth flow now. Press Enter here to close + persist cookies into the profile.",
1607
+ "[--login --attached] Automation-controlled Chrome is open. Drive the visible window now. Press Enter here to close + persist cookies into the profile.",
1494
1608
  "info",
1495
1609
  );
1496
1610
  closeWaits.push(waitForTerminalEnter(lifecycleAbort.signal));
@@ -1687,9 +1801,161 @@ async function runBrowse(
1687
1801
  }
1688
1802
 
1689
1803
  // ---------------------------------------------------------------------------
1690
- // Batch mode parser. Each step is `<verb> <args>`; verbs are click, fill,
1691
- // press, wait, eval. Steps are separated by `;` (escape with `\;` if a value
1692
- // genuinely contains a semicolon, rare for the supported verbs).
1804
+ // Scroll-and-collect mode (--collect). Extends browse rather than adding a
1805
+ // sibling command so it reuses the exact session path: persistent profile,
1806
+ // cookie jar, browser channel, user agent, proxy, page-load pace gate,
1807
+ // --wait-for and --batch setup steps.
1808
+ // ---------------------------------------------------------------------------
1809
+
1810
+ interface CollectPlan {
1811
+ spec: ResolvedCollectSpec;
1812
+ limits: CollectLimits;
1813
+ pacing: ScrollPacing;
1814
+ seed: number;
1815
+ format: "json" | "jsonl";
1816
+ out: string | null;
1817
+ }
1818
+
1819
+ function parsePositiveInt(raw: string, flag: string): number {
1820
+ if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) < 1) {
1821
+ throw new Error(`${flag} must be a positive integer (got "${raw}").`);
1822
+ }
1823
+ return Number.parseInt(raw, 10);
1824
+ }
1825
+
1826
+ function buildCollectPlan(opts: BrowseOpts): CollectPlan {
1827
+ if (opts.login) throw new Error("--collect cannot run with --login.");
1828
+ if (opts.snapshot || opts.html || opts.json) {
1829
+ throw new Error("--collect prints its own JSON; drop --snapshot, --html and --json.");
1830
+ }
1831
+ const presetName = typeof opts.collect === "string" ? opts.collect : "generic";
1832
+ const spec = resolveCollectSpec(presetName, {
1833
+ item: opts.collectItem,
1834
+ key: opts.collectKey,
1835
+ keyPattern: opts.collectKeyPattern,
1836
+ fields: opts.collectField,
1837
+ expand: opts.collectExpand,
1838
+ });
1839
+ const limits: CollectLimits = {
1840
+ ...DEFAULT_COLLECT_LIMITS,
1841
+ maxItems:
1842
+ opts.collectMaxItems === undefined
1843
+ ? null
1844
+ : parsePositiveInt(opts.collectMaxItems, "--collect-max-items"),
1845
+ maxScrolls: parsePositiveInt(opts.collectMaxScrolls ?? "60", "--collect-max-scrolls"),
1846
+ maxMs: parsePositiveInt(opts.collectMaxSeconds ?? "300", "--collect-max-seconds") * 1_000,
1847
+ idleSteps: parsePositiveInt(opts.collectIdle ?? "5", "--collect-idle"),
1848
+ expandPerStep: spec.expand ? 3 : 0,
1849
+ };
1850
+ const pacing = scrollPacingFromEnv();
1851
+ const msBounds = { min: 0, max: PACING_LIMITS.maxPauseMs };
1852
+ if (opts.collectStep) {
1853
+ pacing.step = parseRange(opts.collectStep, "--collect-step", {
1854
+ min: 0.05,
1855
+ max: PACING_LIMITS.maxStepFraction,
1856
+ });
1857
+ }
1858
+ if (opts.collectPause)
1859
+ pacing.pauseMs = parseRange(opts.collectPause, "--collect-pause", msBounds);
1860
+ if (opts.collectReadPause) {
1861
+ pacing.readPauseMs = parseRange(opts.collectReadPause, "--collect-read-pause", msBounds);
1862
+ }
1863
+ if (opts.collectReadChance !== undefined) {
1864
+ pacing.readChance = parseProbability(opts.collectReadChance, "--collect-read-chance");
1865
+ }
1866
+ if (opts.collectBackChance !== undefined) {
1867
+ pacing.backChance = parseProbability(opts.collectBackChance, "--collect-back-chance");
1868
+ }
1869
+ let seed = randomSeed();
1870
+ if (opts.collectSeed !== undefined) {
1871
+ if (!/^\d+$/.test(opts.collectSeed.trim())) {
1872
+ throw new Error(`--collect-seed must be a non-negative integer (got "${opts.collectSeed}").`);
1873
+ }
1874
+ seed = Number(BigInt(opts.collectSeed.trim()) % 4294967296n);
1875
+ }
1876
+ const format = opts.collectFormat ?? "json";
1877
+ if (format !== "json" && format !== "jsonl") {
1878
+ throw new Error(`--collect-format must be json or jsonl (got "${format}").`);
1879
+ }
1880
+ return {
1881
+ spec,
1882
+ limits,
1883
+ pacing,
1884
+ seed,
1885
+ format,
1886
+ out: opts.collectOut ? resolve(opts.collectOut) : null,
1887
+ };
1888
+ }
1889
+
1890
+ async function runCollectMode(browser: Browser, plan: CollectPlan): Promise<void> {
1891
+ const page = browser.currentPage;
1892
+ const rng = createRng(plan.seed);
1893
+ let interrupted = false;
1894
+ const onSignal = () => {
1895
+ interrupted = true;
1896
+ };
1897
+ process.on("SIGINT", onSignal);
1898
+ process.on("SIGTERM", onSignal);
1899
+ let result: Awaited<ReturnType<typeof runCollect>>;
1900
+ try {
1901
+ emit.log(
1902
+ `collect: preset ${plan.spec.preset}, item ${plan.spec.item}, seed ${plan.seed}`,
1903
+ "info",
1904
+ );
1905
+ const driver = createPageDriver(page, plan.spec, {
1906
+ rng,
1907
+ log: (message) => emit.log(message, "warn"),
1908
+ });
1909
+ result = await runCollect(driver, {
1910
+ limits: plan.limits,
1911
+ pacing: plan.pacing,
1912
+ rng,
1913
+ shouldStop: () => interrupted,
1914
+ onStep: ({ step, items, added }) =>
1915
+ emit.log(`collect: step ${step}, ${items} items (+${added})`, "debug"),
1916
+ });
1917
+ } finally {
1918
+ process.removeListener("SIGINT", onSignal);
1919
+ process.removeListener("SIGTERM", onSignal);
1920
+ }
1921
+ const { items, stats } = result;
1922
+ emit.log(
1923
+ `collect: ${items.length} items in ${stats.steps} steps, stopped on ${stats.stopReason}` +
1924
+ (stats.stopDetail ? ` (${stats.stopDetail})` : ""),
1925
+ "info",
1926
+ );
1927
+ const envelope = {
1928
+ url: page.url(),
1929
+ preset: plan.spec.preset,
1930
+ itemSelector: plan.spec.item,
1931
+ seed: plan.seed,
1932
+ stopReason: stats.stopReason,
1933
+ stats,
1934
+ items,
1935
+ };
1936
+ if (plan.format === "jsonl") {
1937
+ const body = items.map((item) => JSON.stringify(item)).join("\n");
1938
+ if (plan.out) {
1939
+ mkdirSync(dirname(plan.out), { recursive: true });
1940
+ writeFileSync(plan.out, body ? `${body}\n` : "");
1941
+ emit.file(plan.out, { items: items.length, stopReason: stats.stopReason, stats });
1942
+ } else if (body) {
1943
+ process.stdout.write(`${body}\n`);
1944
+ }
1945
+ } else if (plan.out) {
1946
+ mkdirSync(dirname(plan.out), { recursive: true });
1947
+ writeFileSync(plan.out, `${JSON.stringify(envelope, null, 2)}\n`);
1948
+ emit.file(plan.out, { items: items.length, stopReason: stats.stopReason, stats });
1949
+ } else {
1950
+ emit.data(envelope);
1951
+ }
1952
+ if (interrupted) process.exitCode = 130;
1953
+ }
1954
+
1955
+ // ---------------------------------------------------------------------------
1956
+ // Batch mode runner. Each step is `<verb> <args>`; see splitBatchSteps for
1957
+ // the separator rule (eval steps keep semicolons inside JS quotes and
1958
+ // brackets; `\;` is always a literal semicolon).
1693
1959
  // ---------------------------------------------------------------------------
1694
1960
 
1695
1961
  interface BatchResult {
@@ -1769,27 +2035,6 @@ async function runBatch(
1769
2035
  return result;
1770
2036
  }
1771
2037
 
1772
- function splitBatchSteps(input: string): string[] {
1773
- const steps: string[] = [];
1774
- let buf = "";
1775
- for (let i = 0; i < input.length; i++) {
1776
- const ch = input[i];
1777
- if (ch === "\\" && input[i + 1] === ";") {
1778
- buf += ";";
1779
- i++;
1780
- continue;
1781
- }
1782
- if (ch === ";") {
1783
- steps.push(buf);
1784
- buf = "";
1785
- continue;
1786
- }
1787
- buf += ch;
1788
- }
1789
- if (buf.trim()) steps.push(buf);
1790
- return steps;
1791
- }
1792
-
1793
2038
  // ---------------------------------------------------------------------------
1794
2039
  // Print mode (--snapshot / --html / --json): stdout, no file writes
1795
2040
  // ---------------------------------------------------------------------------
@@ -3123,7 +3368,28 @@ async function verifyBrowserProxyGate(
3123
3368
  }
3124
3369
 
3125
3370
  /**
3126
- * `--login --plain`: spawn the installed Google Chrome directly on the shared
3371
+ * How a `browse` run handles sign-in. `--login` opens the installed Google
3372
+ * Chrome with nothing attached, because sites can detect an
3373
+ * automation-controlled browser and disable the account that signed in with
3374
+ * it. The attached window is reachable only through an explicit `--attached`.
3375
+ */
3376
+ export function resolveLoginMode(
3377
+ opts: Pick<BrowseOpts, "login" | "attached" | "controlFile" | "loginCloseFile">,
3378
+ ): "none" | "plain" | "attached" {
3379
+ if (opts.attached && !opts.login) throw new Error("--attached requires --login.");
3380
+ if (opts.loginCloseFile && !opts.login) throw new Error("--login-close-file requires --login.");
3381
+ if (opts.controlFile && !(opts.login && opts.attached)) {
3382
+ throw new Error(
3383
+ "--control-file requires --login --attached. A sign-in to a third-party account uses plain " +
3384
+ "--login, which nothing can drive.",
3385
+ );
3386
+ }
3387
+ if (!opts.login) return "none";
3388
+ return opts.attached ? "attached" : "plain";
3389
+ }
3390
+
3391
+ /**
3392
+ * `--login`: spawn the installed Google Chrome directly on the shared
3127
3393
  * persistent profile, with no DevTools protocol attached. Playwright enables
3128
3394
  * its runtime hooks in every frame it drives, and sign-in verification vendors
3129
3395
  * (X's is the reported case) detect that and fail the flow with no explanation.
@@ -3141,7 +3407,8 @@ async function runPlainLogin(
3141
3407
  const executable = installedChromePath();
3142
3408
  if (!executable) {
3143
3409
  throw new Error(
3144
- "--plain needs an installed Google Chrome at a standard path; none was found on this machine.",
3410
+ "--login needs an installed Google Chrome at a standard path; none was found on this machine. " +
3411
+ "Install Google Chrome; do not fall back to --attached for a third-party sign-in.",
3145
3412
  );
3146
3413
  }
3147
3414
  mkdirSync(profileDir, { recursive: true });
@@ -3164,13 +3431,13 @@ async function runPlainLogin(
3164
3431
  const waits: Promise<void>[] = [exited];
3165
3432
  if (loginCloseFile) {
3166
3433
  emit.log(
3167
- `[--login --plain] Plain Google Chrome is open with nothing attached. Sign in, then close the window or create ${loginCloseFile}.`,
3434
+ `[--login] Plain Google Chrome is open with nothing attached. Sign in, then close the window or create ${loginCloseFile}.`,
3168
3435
  "info",
3169
3436
  );
3170
3437
  waits.push(waitForLoginCloseFile(loginCloseFile, lifecycleAbort.signal));
3171
3438
  } else {
3172
3439
  emit.log(
3173
- "[--login --plain] Plain Google Chrome is open with nothing attached. Sign in, then close the window or press Enter here.",
3440
+ "[--login] Plain Google Chrome is open with nothing attached. Sign in, then close the window or press Enter here.",
3174
3441
  "info",
3175
3442
  );
3176
3443
  waits.push(waitForTerminalEnter(lifecycleAbort.signal));
@@ -3181,7 +3448,83 @@ async function runPlainLogin(
3181
3448
  child.kill("SIGTERM");
3182
3449
  await Promise.race([exited, new Promise((r) => setTimeout(r, 5000))]);
3183
3450
  }
3184
- emit.log(`[--login --plain] Chrome closed; cookies persisted in ${profileDir}.`, "info");
3451
+ emit.log(`[--login] Chrome closed; cookies persisted in ${profileDir}.`, "info");
3452
+ }
3453
+
3454
+ /**
3455
+ * Under WSL, `--login` opens the Windows host's Google Chrome when one
3456
+ * is installed. Some sign-in flows reject every browser running inside WSL,
3457
+ * even a plain Linux Chrome, while the Windows desktop Chrome signs in
3458
+ * normally. `HARNERY_BROWSER_WSL_PLAIN=linux` keeps the Linux Chrome.
3459
+ */
3460
+ function useWindowsPlainLogin(): boolean {
3461
+ if (!isWSL()) return false;
3462
+ if ((process.env.HARNERY_BROWSER_WSL_PLAIN ?? "").toLowerCase() === "linux") return false;
3463
+ return windowsChromePath() !== undefined;
3464
+ }
3465
+
3466
+ /**
3467
+ * The WSL form of `--login`: the person signs in through the Windows
3468
+ * Chrome on a dedicated Windows profile with nothing attached. When the window
3469
+ * closes, the profile's cookies for the sign-in site are read and merged into
3470
+ * the shared cookie store, which later `browse` runs attach.
3471
+ */
3472
+ async function runWindowsPlainLogin(
3473
+ url: string,
3474
+ storePath: string,
3475
+ loginCloseFile: string | null,
3476
+ emit: { log: (message: string, level: "info" | "warn" | "error" | "debug") => void },
3477
+ ): Promise<void> {
3478
+ const chromeLinux = windowsChromePath();
3479
+ const localAppData = windowsLocalAppData();
3480
+ if (!chromeLinux || !localAppData) {
3481
+ throw new Error(
3482
+ "--login under WSL needs the Windows Google Chrome and %LOCALAPPDATA%; neither was readable.",
3483
+ );
3484
+ }
3485
+ const chromeWindows = wslPathToWindows(chromeLinux);
3486
+ const profileWindows = windowsPlainProfile(localAppData);
3487
+ const child = spawn(
3488
+ chromeLinux,
3489
+ [`--user-data-dir=${profileWindows}`, "--no-first-run", "--no-default-browser-check", url],
3490
+ { stdio: "ignore" },
3491
+ );
3492
+ const exited = new Promise<void>((resolveExit) => {
3493
+ child.once("exit", () => resolveExit());
3494
+ child.once("error", () => resolveExit());
3495
+ });
3496
+ const lifecycleAbort = new AbortController();
3497
+ const waits: Promise<void>[] = [exited];
3498
+ const how = loginCloseFile ? `create ${loginCloseFile}` : "press Enter here";
3499
+ emit.log(
3500
+ `[--login] Windows Google Chrome is open with nothing attached (profile ${profileWindows}). Sign in, then close the window or ${how}.`,
3501
+ "info",
3502
+ );
3503
+ waits.push(
3504
+ loginCloseFile
3505
+ ? waitForLoginCloseFile(loginCloseFile, lifecycleAbort.signal)
3506
+ : waitForTerminalEnter(lifecycleAbort.signal),
3507
+ );
3508
+ await Promise.race(waits);
3509
+ lifecycleAbort.abort();
3510
+ stopWindowsChromeProfile(profileWindows);
3511
+ await Promise.race([exited, new Promise((r) => setTimeout(r, 5000))]);
3512
+ const domain = harvestDomain(url);
3513
+ const cookies = harvestWindowsChromeCookies({ chromeWindows, profileWindows, domain });
3514
+ if (cookies.length === 0) {
3515
+ emit.log(
3516
+ `[--login] No ${domain} cookies were found in the Windows profile; the sign-in may not have completed.`,
3517
+ "warn",
3518
+ );
3519
+ return;
3520
+ }
3521
+ const jar = new CookieJar({ path: storePath, source: "harn-browse" });
3522
+ jar.save(mergeCookies(jar.load(), cookies));
3523
+ const names = [...new Set(cookies.map((cookie) => cookie.name))].sort().join(", ");
3524
+ emit.log(
3525
+ `[--login] Copied ${cookies.length} ${domain} cookies into ${storePath}: ${names}.`,
3526
+ "info",
3527
+ );
3185
3528
  }
3186
3529
 
3187
3530
  async function waitForLoginCloseFile(path: string, signal?: AbortSignal): Promise<void> {
@@ -8,6 +8,13 @@ import {
8
8
  planMirror,
9
9
  readCliAccount,
10
10
  } from "../lib/claude-desktop.ts";
11
+ import {
12
+ applyShare,
13
+ defaultShareDir,
14
+ planShare,
15
+ shareSummary,
16
+ } from "../lib/claude-desktop-share.ts";
17
+ import { resolveMachineLabel } from "../lib/machine.ts";
11
18
 
12
19
  /**
13
20
  * `claude-desktop`: make Claude desktop-app sessions survive account
@@ -186,6 +193,60 @@ export function registerClaudeDesktopCommand(
186
193
  }
187
194
  },
188
195
  );
196
+
197
+ cmd
198
+ .command("share")
199
+ .description(
200
+ "Share sessions with your other computers through a synced folder (iCloud Drive by default): " +
201
+ "publish this machine's recent sessions and list + resume every other machine's here. " +
202
+ "Dry-run by default; --yes applies. Restart the desktop app afterward to see new entries.",
203
+ )
204
+ .option("--dir <path>", "Shared folder (default: iCloud Drive/Harnery/claude-sessions)")
205
+ .option("--days <n>", "Only sessions active in the last N days", "14")
206
+ .option("--data-dir <path>", "Explicit desktop-app data directory")
207
+ .option("--yes", "Apply the plan (default is dry-run)")
208
+ .action((opts: { dir?: string; days: string; dataDir?: string; yes?: boolean }) => {
209
+ const shareDir = opts.dir ?? process.env.HARNERY_SESSION_SHARE_DIR ?? defaultShareDir();
210
+ if (!shareDir) {
211
+ emit.error({
212
+ code: "no_share_dir",
213
+ message: "no shared folder: pass --dir <path> or set HARNERY_SESSION_SHARE_DIR",
214
+ });
215
+ process.exit(2);
216
+ }
217
+ const cli = readCliAccount();
218
+ if (!cli) {
219
+ emit.error({
220
+ code: "no_account",
221
+ message: "cannot read the signed-in account from ~/.claude.json",
222
+ });
223
+ process.exit(2);
224
+ }
225
+ const [dataDir] = requireDataDirs(emit, opts.dataDir);
226
+ const shareOpts = {
227
+ shareDir,
228
+ machine: resolveMachineLabel(),
229
+ days: Number(opts.days) || 14,
230
+ dataDir: dataDir as string,
231
+ targetAccountUuid: cli.accountUuid,
232
+ };
233
+ const plan = planShare(shareOpts);
234
+ const summary = { share_dir: shareDir, machine: shareOpts.machine, ...shareSummary(plan) };
235
+ if (!opts.yes) {
236
+ emit.data({ dry_run: true, ...summary, hint: "re-run with --yes to apply" });
237
+ return;
238
+ }
239
+ const result = applyShare(plan, shareOpts);
240
+ emit.data({
241
+ dry_run: false,
242
+ ...summary,
243
+ ...result,
244
+ hint:
245
+ result.imported > 0
246
+ ? "quit the Claude desktop app fully (Cmd+Q) and reopen it to see the new sessions"
247
+ : "sidebar already current",
248
+ });
249
+ });
189
250
  }
190
251
 
191
252
  function collect(value: string, previous: string[]): string[] {