@reticlehq/server 3.8.0 → 3.8.2

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 (115) hide show
  1. package/SKILL.md +2 -2
  2. package/dist/command/cli/answers/gate-hook-message.d.ts +2 -0
  3. package/dist/command/cli/answers/gate-hook-message.js +5 -1
  4. package/dist/command/cli/auth/cloud-session.d.ts +1 -19
  5. package/dist/command/cli/auth/device-flow.d.ts +3 -37
  6. package/dist/command/cli/cli-doctor.js +30 -23
  7. package/dist/command/cli/cli-flow-commands.js +7 -1
  8. package/dist/command/cli/cli-parse-serve.d.ts +24 -0
  9. package/dist/command/cli/cli-parse-serve.js +124 -0
  10. package/dist/command/cli/cli-parse.d.ts +1 -10
  11. package/dist/command/cli/cli-parse.js +62 -121
  12. package/dist/command/cli/cli-usage.js +5 -2
  13. package/dist/command/cli/cloud-link.d.ts +1 -20
  14. package/dist/command/cli/doctor/doctor-agent-link.d.ts +23 -0
  15. package/dist/command/cli/doctor/doctor-agent-link.js +28 -0
  16. package/dist/command/cli/ports/port-holder.js +6 -1
  17. package/dist/command/cli/project-name.d.ts +1 -7
  18. package/dist/command/cli/project-name.js +1 -0
  19. package/dist/command/daemon/daemon.d.ts +6 -0
  20. package/dist/command/daemon/daemon.js +52 -2
  21. package/dist/command/daemon/win32-escape.d.ts +32 -0
  22. package/dist/command/daemon/win32-escape.js +119 -0
  23. package/dist/command/setup/bringup/dev-server-files.d.ts +1 -9
  24. package/dist/command/setup/bringup/dev-server-wait.js +5 -1
  25. package/dist/command/version/version-skew.d.ts +2 -0
  26. package/dist/command/version/version-skew.js +7 -3
  27. package/dist/features/crawl/crawl-tools.js +2 -2
  28. package/dist/features/exhaust/ledger.d.ts +15 -96
  29. package/dist/features/exhaust/ledger.js +3 -3
  30. package/dist/features/license/license.d.ts +2 -14
  31. package/dist/hooks/hook-commands.js +19 -9
  32. package/dist/judgement/capsule/capsule-store.d.ts +2 -39
  33. package/dist/language/flows/annotate-tools.js +1 -1
  34. package/dist/language/flows/fields/flow-secret-field.js +1 -1
  35. package/dist/language/flows/flow-expect-grammar.js +14 -6
  36. package/dist/language/flows/flow-lease.d.ts +27 -0
  37. package/dist/language/flows/flow-lease.js +59 -0
  38. package/dist/language/flows/flow-replay-run.d.ts +1 -87
  39. package/dist/language/flows/flow-replay-run.js +88 -283
  40. package/dist/language/flows/flow-replayable.d.ts +1 -1
  41. package/dist/language/flows/flow-start-path.d.ts +108 -0
  42. package/dist/language/flows/flow-start-path.js +313 -0
  43. package/dist/language/flows/flow-tools.d.ts +2 -23
  44. package/dist/language/flows/flow-tools.js +34 -66
  45. package/dist/language/flows/recording/flake.d.ts +3 -27
  46. package/dist/language/flows/recording/flake.js +1 -1
  47. package/dist/language/flows/stores/assertion-tiers-store.js +1 -1
  48. package/dist/machine/git-tracks.js +1 -0
  49. package/dist/memory/cloud/cloud-sync.d.ts +2 -24
  50. package/dist/memory/intent/intent-shard.d.ts +41 -266
  51. package/dist/memory/journal/envelope-store.js +1 -1
  52. package/dist/memory/journal/envelope.d.ts +6 -154
  53. package/dist/memory/journal/on-disk/ambient-file.d.ts +1 -7
  54. package/dist/memory/journal/on-disk/ambient-file.js +1 -1
  55. package/dist/memory/journal/self-observation.js +3 -1
  56. package/dist/memory/journal/session-journal.js +39 -20
  57. package/dist/portal/input/drive-error.d.ts +6 -0
  58. package/dist/portal/input/drive-error.js +9 -0
  59. package/dist/portal/input/real-input-press.d.ts +34 -0
  60. package/dist/portal/input/real-input-press.js +150 -0
  61. package/dist/portal/input/real-input.d.ts +6 -23
  62. package/dist/portal/input/real-input.js +95 -192
  63. package/dist/portal/input/viewport-tools.d.ts +19 -15
  64. package/dist/portal/pool/browser-pool.d.ts +5 -55
  65. package/dist/portal/pool/browser-pool.js +121 -100
  66. package/dist/portal/pool/pool-lease.d.ts +128 -0
  67. package/dist/portal/pool/pool-lease.js +49 -0
  68. package/dist/portal/production/production-activation.d.ts +5 -27
  69. package/dist/portal/session/control-envelope.d.ts +1 -1
  70. package/dist/portal/session/zero-install-first-call.d.ts +3 -0
  71. package/dist/portal/session/zero-install-first-call.js +33 -12
  72. package/dist/surface/mcp/mcp.d.ts +3 -3
  73. package/dist/surface/mcp/mcp.js +4 -6
  74. package/dist/surface/tools/act/act-danger.js +32 -17
  75. package/dist/surface/tools/act/durable.js +1 -1
  76. package/dist/surface/tools/act-sequence-tool.js +3 -3
  77. package/dist/surface/tools/act-tools.js +15 -16
  78. package/dist/surface/tools/agent/compact-envelope.d.ts +2 -2
  79. package/dist/surface/tools/coverage-tools.js +1 -1
  80. package/dist/surface/tools/dynamic-tools.d.ts +8 -0
  81. package/dist/surface/tools/dynamic-tools.js +36 -16
  82. package/dist/surface/tools/empty-session-list.d.ts +5 -29
  83. package/dist/surface/tools/feedback-tools.js +1 -1
  84. package/dist/surface/tools/gap-novelty.d.ts +1 -1
  85. package/dist/surface/tools/gaps/capability-absences.js +4 -3
  86. package/dist/surface/tools/harness-gate.d.ts +1 -1
  87. package/dist/surface/tools/harness-toolset.js +3 -4
  88. package/dist/surface/tools/lease-permissions.d.ts +1 -1
  89. package/dist/surface/tools/lease-readiness.d.ts +23 -1
  90. package/dist/surface/tools/lease-readiness.js +75 -9
  91. package/dist/surface/tools/lease-tools.d.ts +1 -52
  92. package/dist/surface/tools/lease-tools.js +79 -142
  93. package/dist/surface/tools/observe-tools.js +3 -1
  94. package/dist/surface/tools/read-tools.js +5 -5
  95. package/dist/surface/tools/real-input-attempt.js +70 -31
  96. package/dist/surface/tools/session-ports.d.ts +57 -0
  97. package/dist/surface/tools/session-ports.js +102 -0
  98. package/dist/surface/tools/tool-kit.d.ts +5 -3
  99. package/dist/surface/tools/tools.js +1 -1
  100. package/dist/telemetry/daemon-telemetry.d.ts +3 -1
  101. package/dist/telemetry/daemon-telemetry.js +9 -2
  102. package/dist/telemetry/telemetry.d.ts +3 -1
  103. package/dist/telemetry/telemetry.js +2 -0
  104. package/docs/agent-cheatsheet.md +12 -0
  105. package/docs/cli/gate.mdx +1 -1
  106. package/docs/cli/verify.mdx +8 -1
  107. package/docs/first-drive.md +7 -0
  108. package/docs/multi-agent-testing.md +2 -0
  109. package/docs/packages/react.mdx +14 -2
  110. package/docs/packages/vite-plugin.mdx +1 -0
  111. package/docs/predicates.mdx +6 -0
  112. package/docs/skill.md +3 -3
  113. package/docs/troubleshooting.mdx +10 -0
  114. package/docs/usage.md +1 -1
  115. package/package.json +8 -9
@@ -4,11 +4,13 @@
4
4
  * and re-exports this surface.
5
5
  */
6
6
  import { parseFeedbackArgs } from './cli-parse-feedback.js';
7
+ import { HEADLESS_FLAG, parseServeFlags } from './cli-parse-serve.js';
7
8
  import { parseVerifySuffix } from './cli-parse-verify.js';
8
9
  import { HEADED_FLAG, PORT_FLAG, VERIFY_COMMAND, missingOperand, missingValue, notANumber, retiredFlag, unknownArgument, CLOUD_COMMANDS, REMOVED_COMMANDS, removedNote, } from './cli-parse-grammar.js';
9
10
  // Re-exported because the daemon's own argv builder reaches for these through this module, which is
10
11
  // where the grammar has always lived as far as every caller is concerned.
11
12
  export { HEADED_FLAG, PORT_FLAG };
13
+ export { DRIVE_FLAG, HEADLESS_FLAG, HTTP_FLAG, HTTP_PORT_FLAG, HTTP_TOKEN_FLAG, } from './cli-parse-serve.js';
12
14
  import { FORCE_FLAG } from './cli-kill.js';
13
15
  // Re-exported so every existing importer of these flags is unaffected by the file split.
14
16
  export { AGENT_FLAG, BUG_FLAG, CONTACT_FLAG, FEEDBACK_KINDS, KIND_FLAG, RATING_FLAG, } from './cli-parse-feedback.js';
@@ -122,24 +124,19 @@ const COMMAND_FLAG_ALIASES = new Map([
122
124
  */
123
125
  function numberFlag(args, name) {
124
126
  const at = args.indexOf(name);
125
- if (at < 0)
127
+ if (at < 0) {
126
128
  return 0;
129
+ }
127
130
  const raw = Number(args[at + 1]);
128
131
  return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 0;
129
132
  }
130
133
  /** The subcommand name if we recognize it, else `unknown`. Bare `reticle` reports `help`. */
131
134
  export function knownCommand(arg) {
132
- if (arg === undefined || '' === arg)
135
+ if (arg === undefined || '' === arg) {
133
136
  return HELP_COMMAND;
137
+ }
134
138
  return KNOWN_COMMANDS.has(arg) ? arg : (COMMAND_FLAG_ALIASES.get(arg) ?? UNKNOWN_COMMAND);
135
139
  }
136
- /**
137
- * Force a hidden browser. The default is now HEADED: a run nobody can see is a run nobody trusts,
138
- * and every "did it actually do anything?" question cost a round-trip. CI passes this (or just sets
139
- * CI, which flips the default) because there is no display there to be headed on.
140
- */
141
- export const HEADLESS_FLAG = '--headless';
142
- export const DRIVE_FLAG = '--drive';
143
140
  const QUIET_FLAG = '--quiet';
144
141
  const DRY_RUN_FLAG = '--dry-run';
145
142
  const YES_FLAG = '--yes';
@@ -211,9 +208,6 @@ const LICENSE_FLAG = '--license';
211
208
  const NO_INSTALL_FLAG = '--no-install';
212
209
  /** Give this checkout a project id of its own: a copied repository keeps the original's. */
213
210
  const NEW_ID_FLAG = '--new-id';
214
- export const HTTP_FLAG = '--http';
215
- export const HTTP_PORT_FLAG = '--http-port';
216
- export const HTTP_TOKEN_FLAG = '--http-token';
217
211
  /**
218
212
  * A usage error names the thing that was wrong.
219
213
  *
@@ -226,98 +220,19 @@ export const HTTP_TOKEN_FLAG = '--http-token';
226
220
  * The help text is still shown — cli.ts renders it as readable text underneath. It is just not the
227
221
  * message any more.
228
222
  */
229
- const requiresHttp = (flag) => ({
230
- kind: 'error',
231
- message: `${flag} requires ${HTTP_FLAG} — it configures the verify endpoint ${HTTP_FLAG} starts`,
232
- });
233
223
  const unknownCommand = (command) => ({
234
224
  kind: 'error',
235
225
  message: `unknown command '${command}'`,
236
226
  });
237
- function parseServeFlags(args, defaultPort, defaultHeadless) {
238
- let port = defaultPort;
239
- let driveUrl;
240
- let headless = defaultHeadless;
241
- let http = false;
242
- let httpPort;
243
- let httpToken;
244
- let i = 0;
245
- while (i < args.length) {
246
- const arg = args[i];
247
- // `i < args.length` already guarantees this, but the index signature does not say so and the
248
- // unknown-argument error needs a real string to name.
249
- if (arg === undefined)
250
- break;
251
- if (arg === PORT_FLAG) {
252
- i++;
253
- const n = args[i];
254
- if (n === undefined)
255
- return missingValue(PORT_FLAG);
256
- const parsed = parseInt(n, 10);
257
- if (isNaN(parsed))
258
- return notANumber(PORT_FLAG, n);
259
- port = parsed;
260
- }
261
- else if (arg === DRIVE_FLAG) {
262
- i++;
263
- driveUrl = args[i];
264
- if (driveUrl === undefined)
265
- return missingValue(DRIVE_FLAG);
266
- }
267
- else if (arg === HEADED_FLAG) {
268
- headless = false;
269
- }
270
- else if (arg === HEADLESS_FLAG) {
271
- headless = true;
272
- }
273
- else if (arg === HTTP_FLAG) {
274
- http = true;
275
- }
276
- else if (arg === HTTP_PORT_FLAG) {
277
- i++;
278
- const n = args[i];
279
- if (n === undefined)
280
- return missingValue(HTTP_PORT_FLAG);
281
- const parsed = parseInt(n, 10);
282
- if (isNaN(parsed))
283
- return notANumber(HTTP_PORT_FLAG, n);
284
- httpPort = parsed;
285
- }
286
- else if (arg === HTTP_TOKEN_FLAG) {
287
- i++;
288
- httpToken = args[i];
289
- if (httpToken === undefined)
290
- return missingValue(HTTP_TOKEN_FLAG);
291
- }
292
- else {
293
- return unknownArgument(arg);
294
- }
295
- i++;
296
- }
297
- // Without `--http` these flags configure an endpoint nothing starts, and they were accepted and
298
- // silently ignored — worse than being rejected, because the whole reason to pass them is to be
299
- // honoured (#687).
300
- if (!http && httpPort !== undefined)
301
- return requiresHttp(HTTP_PORT_FLAG);
302
- if (!http && httpToken !== undefined)
303
- return requiresHttp(HTTP_TOKEN_FLAG);
304
- return {
305
- kind: 'ok',
306
- port,
307
- headless,
308
- http,
309
- ...(driveUrl !== undefined ? { driveUrl } : {}),
310
- ...(httpPort !== undefined ? { httpPort } : {}),
311
- ...(httpToken !== undefined ? { httpToken } : {}),
312
- };
313
- }
314
227
  function parsePortFlag(args, defaultPort) {
315
228
  const idx = args.indexOf(PORT_FLAG);
316
- if (-1 === idx)
229
+ if (-1 === idx) {
317
230
  return defaultPort;
231
+ }
318
232
  const n = args[idx + 1];
319
- if (n === undefined)
233
+ if (n === undefined) {
320
234
  return defaultPort;
235
+ }
321
236
  const parsed = parseInt(n, 10);
322
237
  return isNaN(parsed) ? defaultPort : parsed;
323
238
  }
@@ -341,8 +256,9 @@ function parseDriveSuffix(args, port, defaultHeadless) {
341
256
  return unknownArgument(arg);
342
257
  }
343
258
  }
344
- if (driveUrl === undefined)
259
+ if (driveUrl === undefined) {
345
260
  return missingOperand(DRIVE_COMMAND, 'a url');
261
+ }
346
262
  return { kind: 'ok', port, driveUrl, headless };
347
263
  }
348
264
  function parseInitFlags(args) {
@@ -372,23 +288,27 @@ function parseInitFlags(args) {
372
288
  const arg = args[i];
373
289
  // `i < args.length` already guarantees this, but the index signature does not say so and the
374
290
  // unknown-argument error needs a real string to name.
375
- if (arg === undefined)
291
+ if (arg === undefined) {
376
292
  break;
293
+ }
377
294
  if (arg === PORT_FLAG) {
378
295
  i++;
379
296
  const n = args[i];
380
- if (n === undefined)
297
+ if (n === undefined) {
381
298
  return missingValue(PORT_FLAG);
299
+ }
382
300
  const parsed = parseInt(n, 10);
383
- if (isNaN(parsed))
301
+ if (isNaN(parsed)) {
384
302
  return notANumber(PORT_FLAG, n);
303
+ }
385
304
  port = parsed;
386
305
  }
387
306
  else if (arg === APP_FLAG) {
388
307
  i++;
389
308
  const value = args[i];
390
- if (value === undefined)
309
+ if (value === undefined) {
391
310
  return missingValue(APP_FLAG);
311
+ }
392
312
  app = value;
393
313
  }
394
314
  else if (arg === FLOW_FLAG || arg === NO_DRIVE_FLAG || arg === DRIVE_MODEL_FLAG) {
@@ -397,8 +317,9 @@ function parseInitFlags(args) {
397
317
  else if (arg === ENV_FLAG) {
398
318
  i++;
399
319
  const value = args[i];
400
- if (value === undefined)
320
+ if (value === undefined) {
401
321
  return missingValue(ENV_FLAG);
322
+ }
402
323
  env.push(value);
403
324
  }
404
325
  else if (arg === FILES_ONLY_FLAG) {
@@ -434,25 +355,29 @@ function parseInitFlags(args) {
434
355
  else if (arg === URL_FLAG) {
435
356
  i++;
436
357
  const value = args[i];
437
- if (value === undefined)
358
+ if (value === undefined) {
438
359
  return missingValue(URL_FLAG);
360
+ }
439
361
  url = value;
440
362
  }
441
363
  else if (arg === LICENSE_FLAG) {
442
364
  i++;
443
365
  const value = args[i];
444
- if (value === undefined)
366
+ if (value === undefined) {
445
367
  return missingValue(LICENSE_FLAG);
368
+ }
446
369
  licenseKey = value;
447
370
  }
448
371
  else if (arg === TIMEOUT_FLAG_INIT) {
449
372
  i++;
450
373
  const value = args[i];
451
- if (value === undefined)
374
+ if (value === undefined) {
452
375
  return missingValue(TIMEOUT_FLAG_INIT);
376
+ }
453
377
  const seconds = parseInt(value, 10);
454
- if (isNaN(seconds))
378
+ if (isNaN(seconds)) {
455
379
  return notANumber(TIMEOUT_FLAG_INIT, value);
380
+ }
456
381
  timeoutSeconds = seconds;
457
382
  }
458
383
  else if (arg === NO_MCP_FLAG) {
@@ -527,8 +452,9 @@ function parseTargetArgs(rest) {
527
452
  i += 1;
528
453
  continue;
529
454
  }
530
- if (arg !== undefined && !arg.startsWith('-'))
455
+ if (arg !== undefined && !arg.startsWith('-')) {
531
456
  files.push(arg);
457
+ }
532
458
  }
533
459
  return since === undefined ? { files } : { files, since };
534
460
  }
@@ -538,8 +464,9 @@ function parseTargetArgs(rest) {
538
464
  * workspace itself, and a tutorial only dials one when asked to run.
539
465
  */
540
466
  export function dialsTheDaemon(parsed) {
541
- if ('init' === parsed.kind)
467
+ if ('init' === parsed.kind) {
542
468
  return false;
469
+ }
543
470
  return 'port' in parsed;
544
471
  }
545
472
  /**
@@ -550,16 +477,19 @@ export function dialsTheDaemon(parsed) {
550
477
  */
551
478
  export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
552
479
  // Bare `reticle` is `serve`.
553
- if (0 === argv.length)
480
+ if (0 === argv.length) {
554
481
  return { kind: 'serve', port: defaultPort, headless: defaultHeadless, http: false };
482
+ }
555
483
  const [cmd, ...rest] = argv;
556
484
  // Guaranteed by the bare-argv check above; restated so the unknown-command error can name it.
557
- if (cmd === undefined)
485
+ if (cmd === undefined) {
558
486
  return { kind: 'error', message: CLI_USAGE };
487
+ }
559
488
  // `version` (or the conventional -v/--version flags) prints the running version — the diagnostic the
560
489
  // troubleshooting docs lean on to confirm which npx-resolved build is actually executing.
561
- if (cmd === VERSION_COMMAND || '--version' === cmd || '-v' === cmd)
490
+ if (cmd === VERSION_COMMAND || '--version' === cmd || '-v' === cmd) {
562
491
  return { kind: 'version' };
492
+ }
563
493
  // `help` (and the conventional -h/--help) print usage to stdout and exit 0 — the universal first move
564
494
  // for a new user, which otherwise fell through to a JSON error with exit 1.
565
495
  //
@@ -573,23 +503,27 @@ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
573
503
  // Removed commands answer before help does: `reticle hunt --help` should say it is gone, not
574
504
  // describe something that no longer runs.
575
505
  const removed = REMOVED_COMMANDS.get(cmd);
576
- if (removed !== undefined)
506
+ if (removed !== undefined) {
577
507
  return { kind: 'removed', message: removedNote(cmd, removed) };
508
+ }
578
509
  if (HELP_COMMAND === cmd || HELP_FLAGS.has(cmd)) {
579
510
  // `reticle help all`, `reticle --help all`, `reticle help init`.
580
511
  const topic = rest.find((arg) => !HELP_FLAGS.has(arg));
581
512
  return topic === undefined ? { kind: 'help' } : { kind: 'help', topic };
582
513
  }
583
- if (argv.some((arg) => HELP_FLAGS.has(arg)))
514
+ if (argv.some((arg) => HELP_FLAGS.has(arg))) {
584
515
  return { kind: 'help', topic: cmd };
516
+ }
585
517
  // The list is the gate, so a command cannot be routed without also being named in telemetry.
586
- if (!LOCAL_COMMAND_SET.has(cmd))
518
+ if (!LOCAL_COMMAND_SET.has(cmd)) {
587
519
  return unknownCommand(cmd);
520
+ }
588
521
  switch (cmd) {
589
522
  case INIT_COMMAND: {
590
523
  const r = parseInitFlags(rest);
591
- if ('error' === r.kind)
524
+ if ('error' === r.kind) {
592
525
  return r;
526
+ }
593
527
  return {
594
528
  kind: 'init',
595
529
  port: r.port,
@@ -615,8 +549,9 @@ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
615
549
  }
616
550
  case SERVE_COMMAND: {
617
551
  const r = parseServeFlags(rest, defaultPort, defaultHeadless);
618
- if ('error' === r.kind)
552
+ if ('error' === r.kind) {
619
553
  return r;
554
+ }
620
555
  return {
621
556
  kind: 'serve',
622
557
  port: r.port,
@@ -631,8 +566,9 @@ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
631
566
  const port = parsePortFlag(rest, defaultPort);
632
567
  // `stop --force` is what `kill` was: free the port by its listener, which must still be a
633
568
  // Reticle daemon. Overriding that refusal stays on `restart --force` and the old `kill --force`.
634
- if (rest.includes(FORCE_FLAG))
569
+ if (rest.includes(FORCE_FLAG)) {
635
570
  return { kind: 'kill', port, force: false };
571
+ }
636
572
  const quiet = rest.includes(QUIET_FLAG);
637
573
  return { kind: 'stop', port, quiet };
638
574
  }
@@ -687,8 +623,9 @@ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
687
623
  }
688
624
  // Only `mcp` today. A bare `reticle setup` falls to help rather than guessing, because the
689
625
  // next subcommand here will not be a synonym for this one.
690
- if (what !== 'mcp')
626
+ if (what !== 'mcp') {
691
627
  return { kind: 'help', topic: SETUP_COMMAND };
628
+ }
692
629
  return { kind: 'setup-mcp' };
693
630
  }
694
631
  case LICENSE_COMMAND:
@@ -715,14 +652,16 @@ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
715
652
  }
716
653
  case DRIVE_COMMAND: {
717
654
  const r = parseDriveSuffix(rest, defaultPort, defaultHeadless);
718
- if ('error' === r.kind)
655
+ if ('error' === r.kind) {
719
656
  return r;
657
+ }
720
658
  return { kind: 'drive', port: r.port, driveUrl: r.driveUrl, headless: r.headless };
721
659
  }
722
660
  case DAEMON_INNER_COMMAND: {
723
661
  const r = parseServeFlags(rest, defaultPort, defaultHeadless);
724
- if ('error' === r.kind)
662
+ if ('error' === r.kind) {
725
663
  return r;
664
+ }
726
665
  return {
727
666
  kind: '_daemon',
728
667
  port: r.port,
@@ -735,8 +674,9 @@ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
735
674
  }
736
675
  case VERIFY_COMMAND: {
737
676
  const r = parseVerifySuffix(rest, defaultPort);
738
- if ('error' === r.kind)
677
+ if ('error' === r.kind) {
739
678
  return r;
679
+ }
740
680
  return {
741
681
  kind: 'verify',
742
682
  url: r.url,
@@ -797,8 +737,9 @@ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
797
737
  return { kind: 'rollback' };
798
738
  case MCP_COMMAND: {
799
739
  const r = parseServeFlags(rest, defaultPort, defaultHeadless);
800
- if ('error' === r.kind)
740
+ if ('error' === r.kind) {
801
741
  return r;
742
+ }
802
743
  return {
803
744
  kind: 'mcp',
804
745
  port: r.port,
@@ -56,6 +56,9 @@ const VERIFY_DETAIL = `Drives the URL and verifies the saved flows. Exit 0 means
56
56
  https://docs.reticle.sh/http-transport.md`;
57
57
  const BROWSER_NOTE = `The browser Reticle opens for your agent is shown so you can watch the run. --headless hides
58
58
  it; CI, RETICLE_HEADLESS=1, or a Linux machine with no display hides it too.`;
59
+ /** What `--http` starts. MCP is already on the bridge port; this flag is the verify endpoint. */
60
+ const HTTP_HELP = '--http starts the verify endpoint. MCP is already served on the bridge port.';
61
+ const DAEMON_DETAIL = `${BROWSER_NOTE}\n\n${HTTP_HELP}`;
59
62
  const COMMANDS = [
60
63
  {
61
64
  name: 'init',
@@ -225,14 +228,14 @@ const COMMANDS = [
225
228
  synopsis: 'mcp [--port N] [--drive <url>] [--headless] [--headed] [--http] [--http-port N] [--http-token T]',
226
229
  summary: 'The MCP stdio server your agent runs; starts the daemon if needed',
227
230
  group: HelpGroup.MORE,
228
- detail: BROWSER_NOTE,
231
+ detail: DAEMON_DETAIL,
229
232
  },
230
233
  {
231
234
  name: 'serve',
232
235
  synopsis: 'serve [--port N] [--drive <url>] [--headless] [--headed] [--http] [--http-port N] [--http-token T]',
233
236
  summary: 'Start the daemon in the background; returns once it answers',
234
237
  group: HelpGroup.MORE,
235
- detail: BROWSER_NOTE,
238
+ detail: DAEMON_DETAIL,
236
239
  },
237
240
  {
238
241
  name: 'drive',
@@ -10,28 +10,9 @@ export declare const validateKey: (url: string, key: string) => Promise<z.infer<
10
10
  declare const WhoamiSchema: z.ZodObject<{
11
11
  projectId: z.ZodString;
12
12
  projectName: z.ZodString;
13
- /**
14
- * Which tenant the key belongs to. Optional for the same reason `dashboardUrl` is — an older
15
- * cloud does not send it — and when it is missing a stored key cannot be proved to be ours, which
16
- * is why the reuse path below mints instead of guessing.
17
- */
18
13
  orgId: z.ZodOptional<z.ZodString>;
19
- /**
20
- * Where this project's dashboard lives. Optional because an older cloud does not send it, and a
21
- * CLI that refused to link against one would break the thing it is supposed to connect.
22
- */
23
14
  dashboardUrl: z.ZodOptional<z.ZodString>;
24
- }, "strip", z.ZodTypeAny, {
25
- projectId: string;
26
- projectName: string;
27
- orgId?: string | undefined;
28
- dashboardUrl?: string | undefined;
29
- }, {
30
- projectId: string;
31
- projectName: string;
32
- orgId?: string | undefined;
33
- dashboardUrl?: string | undefined;
34
- }>;
15
+ }, z.core.$strip>;
35
16
  export interface PlatformLinked {
36
17
  via: PlatformLinkVia;
37
18
  /** The project this folder was bound to before, if any: a re-run against it is not a new link. */
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The agent link row of `reticle doctor`, as one pure decision (reticlehq/reticle#1710).
3
+ *
4
+ * The row used to be built inline from the attach record alone: once a client had attached,
5
+ * enumerated and called, it stayed green forever, and a client that had started but never listed
6
+ * was told to restart the client. Both answers are wrong when the port is held but `/status`
7
+ * does not answer: the record cannot say whether the link works now, and the daemon row above
8
+ * already names the one fix for the held port. So in that state this row only points back at
9
+ * the daemon row. It never prints a second command, and it never says "the daemon", because
10
+ * this state also covers a port held by a different program.
11
+ *
12
+ * The record and action strings below are copied verbatim from the inline row this replaces;
13
+ * this module changes their priority, not their words.
14
+ */
15
+ export interface AgentLinkInput {
16
+ /** The split-brain sentence, when this project is served by another daemon. */
17
+ split: string | undefined;
18
+ /** The attach-record action, or undefined when the record says the link worked. */
19
+ attachAction: string | undefined;
20
+ /** True when the port is held but `/status` does not answer. */
21
+ foreign: boolean;
22
+ }
23
+ export declare function agentLinkText(input: AgentLinkInput): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The agent link row of `reticle doctor`, as one pure decision (reticlehq/reticle#1710).
3
+ *
4
+ * The row used to be built inline from the attach record alone: once a client had attached,
5
+ * enumerated and called, it stayed green forever, and a client that had started but never listed
6
+ * was told to restart the client. Both answers are wrong when the port is held but `/status`
7
+ * does not answer: the record cannot say whether the link works now, and the daemon row above
8
+ * already names the one fix for the held port. So in that state this row only points back at
9
+ * the daemon row. It never prints a second command, and it never says "the daemon", because
10
+ * this state also covers a port held by a different program.
11
+ *
12
+ * The record and action strings below are copied verbatim from the inline row this replaces;
13
+ * this module changes their priority, not their words.
14
+ */
15
+ /** The healthy-record sentence, word for word as doctor printed it before. */
16
+ const RECORD_HEALTHY = '✓ an MCP client attached here and called tools before (a record, not a live check)';
17
+ export function agentLinkText(input) {
18
+ if (input.split !== undefined) {
19
+ return `✗ ${input.split}`;
20
+ }
21
+ if (input.foreign) {
22
+ return '✗ the agent link is not checked live here — follow the daemon row above.';
23
+ }
24
+ if (input.attachAction === undefined) {
25
+ return RECORD_HEALTHY;
26
+ }
27
+ return `✗ ${input.attachAction}`;
28
+ }
@@ -27,7 +27,12 @@ const LOOKUP_TIMEOUT_MS = 2_000;
27
27
  */
28
28
  export function captureLookup(command, args, timeoutMs = LOOKUP_TIMEOUT_MS) {
29
29
  try {
30
- const result = spawnSync(command, [...args], { encoding: 'utf8', timeout: timeoutMs });
30
+ // The daemon has no console: without windowsHide, each lookup opens a visible window on Windows.
31
+ const result = spawnSync(command, [...args], {
32
+ encoding: 'utf8',
33
+ timeout: timeoutMs,
34
+ windowsHide: true,
35
+ });
31
36
  return 0 === result.status && 'string' === typeof result.stdout ? result.stdout : null;
32
37
  }
33
38
  catch {
@@ -34,13 +34,7 @@ localProjectId?: string) => string;
34
34
  export declare const CreatedProjectSchema: z.ZodObject<{
35
35
  projectId: z.ZodString;
36
36
  name: z.ZodString;
37
- }, "strip", z.ZodTypeAny, {
38
- name: string;
39
- projectId: string;
40
- }, {
41
- name: string;
42
- projectId: string;
43
- }>;
37
+ }, z.core.$strip>;
44
38
  /** ` in workspace "<name>"`, or nothing when no name is known. */
45
39
  export declare const inWorkspace: (name: string | undefined) => string;
46
40
  /**
@@ -83,6 +83,7 @@ export function repoIdentity(dir) {
83
83
  cwd: dir,
84
84
  encoding: 'utf8',
85
85
  stdio: ['ignore', 'pipe', 'ignore'],
86
+ windowsHide: true,
86
87
  }).trim();
87
88
  const remote = git('config', '--get', 'remote.origin.url')
88
89
  .replace(/\.git$/, '')
@@ -1,3 +1,4 @@
1
+ import { type EscapeRequest, type EscapeResult } from './win32-escape.js';
1
2
  export { MAX_DAEMON_LOG_BYTES, recoverOversizedLog } from './lifetime/log-cap.js';
2
3
  /** Env override for the whole state directory — see ReticleEnv.STATE_DIR. */
3
4
  export declare const STATE_DIR_ENV: "RETICLE_STATE_DIR";
@@ -72,8 +73,13 @@ export interface SpawnDaemonDeps {
72
73
  spawnChild(command: string, args: readonly string[], options: {
73
74
  detached: boolean;
74
75
  stdio: readonly ('ignore' | number)[];
76
+ env?: Record<string, string | undefined>;
75
77
  }): SpawnedChild;
76
78
  pidAlive(pid: number): boolean;
79
+ /** `process.platform`. Optional so a test that does not care runs the detached start. */
80
+ readonly platform?: string;
81
+ /** Windows only: start the daemon outside the Job Object of this process. See win32-escape.ts. */
82
+ readonly escapeJob?: (request: EscapeRequest) => EscapeResult;
77
83
  /**
78
84
  * A predecessor died without a clean stop; `ageMs` is how long it had run. Optional so a test that
79
85
  * does not care reports nothing. The default sends `daemon_died_unclean`.
@@ -2,11 +2,12 @@ import { join } from 'node:path';
2
2
  import { homedir } from 'node:os';
3
3
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync, readdirSync, statSync, renameSync, writeSync, } from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
- import { daemonRegistryFileName, ReticleEnv } from '@reticlehq/core';
5
+ import { daemonRegistryFileName, DaemonSpawnMethod, ReticleEnv, } from '@reticlehq/core';
6
6
  import { log } from '../../log.js';
7
7
  import { uncleanPredecessor } from './lifetime/unclean-predecessor.js';
8
8
  import { reportDaemonDiedUnclean } from '../../telemetry/proxy-telemetry.js';
9
9
  import { MAX_DAEMON_LOG_BYTES, pruneOldDaemonLogs } from './lifetime/log-cap.js';
10
+ import { escapeJobViaWmi } from './win32-escape.js';
10
11
  // Re-exported from their owner so callers outside this directory — the proxy log, the rotation
11
12
  // tests — keep reaching `daemon` rather than `lifetime`, which is a sink. See lifetime/log-cap.ts.
12
13
  export { MAX_DAEMON_LOG_BYTES, recoverOversizedLog } from './lifetime/log-cap.js';
@@ -197,8 +198,14 @@ function defaultSpawnDaemonDeps() {
197
198
  closeFile: closeSync,
198
199
  fileSize: (path) => (existsSync(path) ? statSync(path).size : 0),
199
200
  renameFile: renameSync,
200
- spawnChild: (command, args, options) => spawn(command, [...args], { detached: options.detached, stdio: [...options.stdio] }),
201
+ spawnChild: (command, args, options) => spawn(command, [...args], {
202
+ detached: options.detached,
203
+ stdio: [...options.stdio],
204
+ ...(options.env === undefined ? {} : { env: options.env }),
205
+ }),
201
206
  pidAlive: isAlive,
207
+ platform: process.platform,
208
+ escapeJob: escapeJobViaWmi,
202
209
  onUncleanDeath: (ageMs) => void reportDaemonDiedUnclean(ageMs),
203
210
  };
204
211
  }
@@ -215,6 +222,24 @@ function reportUncleanAge(pidFilePath, deps) {
215
222
  // An unreadable pidfile has no age to report.
216
223
  }
217
224
  }
225
+ const WINDOWS = 'win32';
226
+ /** The WMI start must never stop a daemon start: a throw reads as "nothing launched". */
227
+ function tryEscapeJob(escape, request) {
228
+ try {
229
+ return escape(request);
230
+ }
231
+ catch {
232
+ return { launched: false };
233
+ }
234
+ }
235
+ function removeQuietly(path) {
236
+ try {
237
+ unlinkSync(path);
238
+ }
239
+ catch {
240
+ // racing another reclaimer — fine
241
+ }
242
+ }
218
243
  /**
219
244
  * Spawn the reticle daemon as a detached background process, redirecting output to the log file.
220
245
  * Writes the PID file from the parent before returning so callers can call isRunning
@@ -307,11 +332,36 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
307
332
  // A log we cannot write is not a reason to refuse to start the daemon.
308
333
  }
309
334
  }
335
+ let method = DaemonSpawnMethod.DETACHED;
336
+ if (WINDOWS === deps.platform && deps.escapeJob !== undefined) {
337
+ const escaped = tryEscapeJob(deps.escapeJob, {
338
+ command: nodeExec,
339
+ args: [scriptPath, ...args],
340
+ cwd: process.cwd(),
341
+ env: { ...process.env, [ReticleEnv.SPAWN_METHOD]: DaemonSpawnMethod.WMI },
342
+ logPath: logFilePath,
343
+ specPath: join(deps.home, `daemon-${port}.spawn.json`),
344
+ });
345
+ if (escaped.launched) {
346
+ deps.closeFile(logFd);
347
+ // No pid in time: release the lock, so the pidfile never names a process that is not ours.
348
+ if (escaped.pid === undefined) {
349
+ deps.closeFile(lockFd);
350
+ removeQuietly(pidFilePath);
351
+ return true;
352
+ }
353
+ writeFileSync(lockFd, String(escaped.pid), 'utf8');
354
+ deps.closeFile(lockFd);
355
+ return true;
356
+ }
357
+ method = DaemonSpawnMethod.WMI_FALLBACK;
358
+ }
310
359
  let child;
311
360
  try {
312
361
  child = deps.spawnChild(nodeExec, [scriptPath, ...args], {
313
362
  detached: true,
314
363
  stdio: ['ignore', logFd, logFd],
364
+ env: { ...process.env, [ReticleEnv.SPAWN_METHOD]: method },
315
365
  });
316
366
  }
317
367
  catch {