@reticlehq/server 3.8.0 → 3.8.1

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 (106) 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/viewport-tools.d.ts +19 -15
  58. package/dist/portal/pool/browser-pool.d.ts +5 -55
  59. package/dist/portal/pool/browser-pool.js +121 -100
  60. package/dist/portal/pool/pool-lease.d.ts +128 -0
  61. package/dist/portal/pool/pool-lease.js +49 -0
  62. package/dist/portal/production/production-activation.d.ts +5 -27
  63. package/dist/portal/session/control-envelope.d.ts +1 -1
  64. package/dist/portal/session/zero-install-first-call.d.ts +3 -0
  65. package/dist/portal/session/zero-install-first-call.js +33 -12
  66. package/dist/surface/mcp/mcp.d.ts +3 -3
  67. package/dist/surface/mcp/mcp.js +4 -6
  68. package/dist/surface/tools/act/act-danger.js +32 -17
  69. package/dist/surface/tools/act/durable.js +1 -1
  70. package/dist/surface/tools/act-sequence-tool.js +3 -3
  71. package/dist/surface/tools/act-tools.js +13 -14
  72. package/dist/surface/tools/agent/compact-envelope.d.ts +2 -2
  73. package/dist/surface/tools/coverage-tools.js +1 -1
  74. package/dist/surface/tools/dynamic-tools.d.ts +8 -0
  75. package/dist/surface/tools/dynamic-tools.js +36 -16
  76. package/dist/surface/tools/empty-session-list.d.ts +5 -29
  77. package/dist/surface/tools/feedback-tools.js +1 -1
  78. package/dist/surface/tools/gap-novelty.d.ts +1 -1
  79. package/dist/surface/tools/gaps/capability-absences.js +4 -3
  80. package/dist/surface/tools/harness-gate.d.ts +1 -1
  81. package/dist/surface/tools/harness-toolset.js +3 -4
  82. package/dist/surface/tools/lease-permissions.d.ts +1 -1
  83. package/dist/surface/tools/lease-readiness.d.ts +23 -1
  84. package/dist/surface/tools/lease-readiness.js +75 -9
  85. package/dist/surface/tools/lease-tools.d.ts +1 -52
  86. package/dist/surface/tools/lease-tools.js +79 -142
  87. package/dist/surface/tools/observe-tools.js +3 -1
  88. package/dist/surface/tools/read-tools.js +5 -5
  89. package/dist/surface/tools/session-ports.d.ts +57 -0
  90. package/dist/surface/tools/session-ports.js +102 -0
  91. package/dist/surface/tools/tool-kit.d.ts +5 -3
  92. package/dist/surface/tools/tools.js +1 -1
  93. package/dist/telemetry/daemon-telemetry.d.ts +3 -1
  94. package/dist/telemetry/daemon-telemetry.js +9 -2
  95. package/dist/telemetry/telemetry.d.ts +3 -1
  96. package/dist/telemetry/telemetry.js +2 -0
  97. package/docs/agent-cheatsheet.md +12 -0
  98. package/docs/cli/gate.mdx +1 -1
  99. package/docs/cli/verify.mdx +8 -1
  100. package/docs/first-drive.md +7 -0
  101. package/docs/multi-agent-testing.md +2 -0
  102. package/docs/predicates.mdx +6 -0
  103. package/docs/skill.md +3 -3
  104. package/docs/troubleshooting.mdx +10 -0
  105. package/docs/usage.md +1 -1
  106. package/package.json +8 -9
@@ -0,0 +1,32 @@
1
+ /** What the launcher must start. */
2
+ export interface EscapeRequest {
3
+ readonly command: string;
4
+ readonly args: readonly string[];
5
+ readonly cwd: string;
6
+ readonly env: Readonly<Record<string, string | undefined>>;
7
+ readonly logPath: string;
8
+ /** The spec file. The launcher writes the daemon pid to `<specPath>.pid`. */
9
+ readonly specPath: string;
10
+ }
11
+ /**
12
+ * `launched: false` means nothing started, so the caller can start the daemon another way.
13
+ * `launched: true` with no pid means a daemon may start, but its pid is not known in time.
14
+ */
15
+ export type EscapeResult = {
16
+ launched: false;
17
+ } | {
18
+ launched: true;
19
+ pid?: number;
20
+ };
21
+ /**
22
+ * The launcher, as `node -e` source. It has no double quote and no backslash, so it passes as one
23
+ * Windows argument in double quotes. Its one argument is the spec path.
24
+ */
25
+ export declare const JOB_ESCAPE_LAUNCHER: string;
26
+ /** The PowerShell script: start the launcher through WMI, then wait for the pid file. */
27
+ export declare function wmiScript(request: EscapeRequest): string;
28
+ /**
29
+ * Start `request` through WMI. Never throws: every failure reads as `launched: false`, unless the
30
+ * launcher already claimed the spec, which means it is starting the daemon.
31
+ */
32
+ export declare function escapeJobViaWmi(request: EscapeRequest, run?: (script: string) => void): EscapeResult;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Start the daemon outside the Windows Job Object of the process that starts it.
3
+ *
4
+ * A child of `spawn(..., { detached: true })` stays in the job of its parent. An agent host can run
5
+ * `reticle mcp` in a job with KILL_ON_JOB_CLOSE, and the close of that job then kills the daemon
6
+ * too. A process that WMI `Win32_Process.Create` starts is in no job of ours.
7
+ *
8
+ * WMI cannot give the new process our stdio or our env. So WMI starts a small launcher, and the
9
+ * launcher does the same detached spawn as `spawnDaemon`: stdout and stderr go to the daemon log,
10
+ * opened for append, and the env is ours. The daemon is a child of the launcher, so it is outside
11
+ * the job too. The launcher reads the spawn spec from a file and writes the daemon pid to a file.
12
+ *
13
+ * The spec file is also the lock between the launcher and the fallback. The launcher claims it with
14
+ * a rename before it starts anything. The parent removes it after the WMI call. Only one of the two
15
+ * can win. If the parent wins, nothing started and the detached fallback is safe.
16
+ */
17
+ import { execFileSync } from 'node:child_process';
18
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
19
+ /**
20
+ * The launcher, as `node -e` source. It has no double quote and no backslash, so it passes as one
21
+ * Windows argument in double quotes. Its one argument is the spec path.
22
+ */
23
+ export const JOB_ESCAPE_LAUNCHER = [
24
+ "const fs=require('fs'),cp=require('child_process');",
25
+ "const f=process.argv[1],t=f+'.taken';",
26
+ 'fs.renameSync(f,t);',
27
+ "const s=JSON.parse(fs.readFileSync(t,'utf8'));",
28
+ 'fs.unlinkSync(t);',
29
+ "const fd=fs.openSync(s.log,'a');",
30
+ "const c=cp.spawn(s.command,s.args,{cwd:s.cwd,env:s.env,detached:true,stdio:['ignore',fd,fd]});",
31
+ "c.on('error',()=>{});",
32
+ 'c.unref();',
33
+ "fs.writeFileSync(f+'.tmp',String(c.pid));",
34
+ "fs.renameSync(f+'.tmp',f+'.pid');",
35
+ ].join('');
36
+ const PID_SUFFIX = '.pid';
37
+ /** How long the script waits for the launcher to report the daemon pid. */
38
+ const PID_WAIT_MS = 5000;
39
+ /** A hard limit on the whole PowerShell call: its start, the WMI call and the pid wait. */
40
+ const WMI_CALL_TIMEOUT_MS = 8000;
41
+ const POWERSHELL = 'powershell.exe';
42
+ const ENCODING_UTF16LE = 'utf16le';
43
+ /** A PowerShell single-quoted literal. */
44
+ function psLiteral(value) {
45
+ return `'${value.replaceAll("'", "''")}'`;
46
+ }
47
+ /** The PowerShell script: start the launcher through WMI, then wait for the pid file. */
48
+ export function wmiScript(request) {
49
+ const commandLine = `"${request.command}" -e "${JOB_ESCAPE_LAUNCHER}" "${request.specPath}"`;
50
+ const pidPath = `${request.specPath}${PID_SUFFIX}`;
51
+ return [
52
+ "$ErrorActionPreference = 'Stop'",
53
+ // SW_HIDE, so the launcher opens no console window.
54
+ '$startup = New-CimInstance -CimClass (Get-CimClass -ClassName Win32_ProcessStartup) -ClientOnly -Property @{ ShowWindow = [uint16]0 }',
55
+ `$null = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${psLiteral(commandLine)}; CurrentDirectory = ${psLiteral(request.cwd)}; ProcessStartupInformation = $startup }`,
56
+ `$deadline = (Get-Date).AddMilliseconds(${PID_WAIT_MS})`,
57
+ `while (-not (Test-Path -LiteralPath ${psLiteral(pidPath)}) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 50 }`,
58
+ ].join('\n');
59
+ }
60
+ function runPowerShell(script) {
61
+ execFileSync(POWERSHELL, [
62
+ '-NoProfile',
63
+ '-NonInteractive',
64
+ '-EncodedCommand',
65
+ Buffer.from(script, ENCODING_UTF16LE).toString('base64'),
66
+ ], { stdio: 'ignore', timeout: WMI_CALL_TIMEOUT_MS, windowsHide: true });
67
+ }
68
+ function removeIfPresent(path) {
69
+ try {
70
+ unlinkSync(path);
71
+ return true;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ function readLauncherPid(pidPath) {
78
+ try {
79
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
80
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ /**
87
+ * Start `request` through WMI. Never throws: every failure reads as `launched: false`, unless the
88
+ * launcher already claimed the spec, which means it is starting the daemon.
89
+ */
90
+ export function escapeJobViaWmi(request, run = runPowerShell) {
91
+ const pidPath = `${request.specPath}${PID_SUFFIX}`;
92
+ try {
93
+ removeIfPresent(pidPath);
94
+ writeFileSync(request.specPath, JSON.stringify({
95
+ command: request.command,
96
+ args: request.args,
97
+ cwd: request.cwd,
98
+ env: request.env,
99
+ log: request.logPath,
100
+ }),
101
+ // The spec holds the env. Only the user may read it, and it lives until the launcher reads it.
102
+ { encoding: 'utf8', mode: 0o600 });
103
+ }
104
+ catch {
105
+ return { launched: false };
106
+ }
107
+ try {
108
+ run(wmiScript(request));
109
+ }
110
+ catch {
111
+ // PowerShell missing, blocked, failed or timed out. The spec says whether the launcher ran.
112
+ }
113
+ if (removeIfPresent(request.specPath)) {
114
+ return { launched: false };
115
+ }
116
+ const pid = readLauncherPid(pidPath);
117
+ removeIfPresent(pidPath);
118
+ return pid === undefined ? { launched: true } : { launched: true, pid };
119
+ }
@@ -13,15 +13,7 @@ declare const HandedOverSchema: z.ZodObject<{
13
13
  pid: z.ZodNumber;
14
14
  url: z.ZodString;
15
15
  appDir: z.ZodString;
16
- }, "strip", z.ZodTypeAny, {
17
- url: string;
18
- pid: number;
19
- appDir: string;
20
- }, {
21
- url: string;
22
- pid: number;
23
- appDir: string;
24
- }>;
16
+ }, z.core.$strip>;
25
17
  export type HandedOver = z.infer<typeof HandedOverSchema>;
26
18
  /**
27
19
  * Written when `init` leaves a server running, so the next `init` can attach to it.
@@ -52,7 +52,11 @@ export function judgeWait(facts) {
52
52
  */
53
53
  const CRA_PORT_BUSY = /Something is already running on port (\d+)/i;
54
54
  const NAMED_PORT_BUSY = /Port (\d+) is (?:already )?in use/i;
55
- const EADDRINUSE_PORT = /(?:EADDRINUSE|address already in use)[^\d]*(\d+)/i;
55
+ // Node prints `address already in use <address>:<port>`; the port sits after the last colon of the
56
+ // address, so the first digit can belong to the host (`0.0.0.0:3000` said port 0, #1846). The scan
57
+ // also stops at the line end: the stack frame under the error (`at Server (node:net:1817:16)`)
58
+ // carries digits of its own, and they are not the port.
59
+ const EADDRINUSE_PORT = /(?:EADDRINUSE|address already in use)[^\d\n]*(?:.*:)?(\d+)/i;
56
60
  export function portBusyMessage(output) {
57
61
  const match = CRA_PORT_BUSY.exec(output) ?? NAMED_PORT_BUSY.exec(output) ?? EADDRINUSE_PORT.exec(output);
58
62
  const port = match?.[1];
@@ -67,6 +67,8 @@ interface SelfIdentity {
67
67
  contract: string;
68
68
  contractParts?: ContractParts | undefined;
69
69
  }
70
+ /** The one line that converges every piece, after the detailed fix. Not said for one version. */
71
+ export declare const SKEW_CONVERGE: string;
70
72
  /**
71
73
  * The one sentence to tell the agent, or undefined when the pair agrees.
72
74
  *
@@ -56,6 +56,9 @@ function namesWeDoNotKnow(peer, self) {
56
56
  ].filter((name) => !RETIRED_WIRE_NAMES.includes(name));
57
57
  return [...new Set(unknown)].sort();
58
58
  }
59
+ /** The one line that converges every piece, after the detailed fix. Not said for one version. */
60
+ export const SKEW_CONVERGE = "To put every piece on one version, update the CLI and the app SDK (`reticle update` in the app's " +
61
+ 'folder does both), then restart the MCP host.';
59
62
  /** The fix for two builds of one version. */
60
63
  const SAME_VERSION_FIX = 'Both say the same version, so one of them is an older build of it: run `reticle restart`, then ' +
61
64
  'reload the page so it loads the SDK the dev server serves now.';
@@ -87,6 +90,7 @@ export function describeSkew(given, self) {
87
90
  // One version, two builds (a daemon running code older than its install, a cached page bundle):
88
91
  // installing that version again changes nothing, so the fix is the restart and the reload.
89
92
  const peer = given.version === self.version ? { ...given, fix: SAME_VERSION_FIX } : given;
93
+ const converge = given.version === self.version ? '' : ` ${SKEW_CONVERGE}`;
90
94
  // Both sides said which names they speak, so compare the names rather than a hash over them.
91
95
  if (peer.contractParts !== undefined && self.contractParts !== undefined) {
92
96
  const unknown = namesWeDoNotKnow(peer.contractParts, self.contractParts);
@@ -100,14 +104,14 @@ export function describeSkew(given, self) {
100
104
  : 'so it was built against a newer contract than this one';
101
105
  return (`version skew: ${versionPhrase(peer, self)}, and ${peer.what} speaks names this daemon does ` +
102
106
  `not know (${unknown.join(', ')}) — ${why}. ` +
103
- `Tools will behave in ways neither side reports. ${peer.fix}`);
107
+ `Tools will behave in ways neither side reports. ${peer.fix}${converge}`);
104
108
  }
105
109
  if (peer.contract === undefined) {
106
110
  if (peer.version === undefined || peer.version === self.version)
107
111
  return undefined;
108
112
  return (`version skew: ${versionPhrase(peer, self)}, and it is too old to report which wire contract ` +
109
113
  `it speaks. They connect anyway and then disagree about behaviour, which usually surfaces as ` +
110
- `a bare -32000 with nothing naming a version. ${peer.fix}`);
114
+ `a bare -32000 with nothing naming a version. ${peer.fix}${converge}`);
111
115
  }
112
116
  /*
113
117
  * A fingerprint alone cannot tell an ADDITION from a RENAME, so this sentence must not claim it can.
@@ -125,7 +129,7 @@ export function describeSkew(given, self) {
125
129
  return (`version skew: ${versionPhrase(peer, self)}, and they were built against different wire ` +
126
130
  `contracts (${peer.contract} vs ${self.contract}). ${peer.what} is too old to say WHICH names ` +
127
131
  `it speaks, so this cannot tell an added name — which breaks nothing — from a renamed one, ` +
128
- `which breaks the call that uses it. Update it and the question goes away. ${peer.fix}`);
132
+ `which breaks the call that uses it. Update it and the question goes away. ${peer.fix}${converge}`);
129
133
  }
130
134
  /**
131
135
  * The fix line for a page whose SDK disagrees with this daemon, with no project to read.
@@ -80,7 +80,7 @@ export const CRAWL_TOOLS = [
80
80
  .optional()
81
81
  .describe('Where the fault is written, as `file:line`; console errors prefer their own stack and fall back to the clicked control.'),
82
82
  })),
83
- counts: z.record(z.number()),
83
+ counts: z.record(z.string(), z.number()),
84
84
  visited: z.array(z.string()).describe('The controls actually clicked, in order.'),
85
85
  coverageNote: z
86
86
  .string()
@@ -92,7 +92,7 @@ export const CRAWL_TOOLS = [
92
92
  .optional()
93
93
  .describe('Controls clicked whose silence could not be judged, e.g. on a background tab. They are NOT reported as dead, and they are not known to work either.'),
94
94
  // Only on `exhaustive` — see features/exhaust/exhaust-run.ts. Loose: it carries the levels.
95
- exhaustive: z.record(z.unknown()).optional(),
95
+ exhaustive: z.record(z.string(), z.unknown()).optional(),
96
96
  },
97
97
  handler: async (deps, args) => {
98
98
  const session = deps.sessions.resolve(asString(args['sessionId']));
@@ -32,110 +32,29 @@ export declare const CoverageLevel: {
32
32
  export type CoverageLevel = (typeof CoverageLevel)[keyof typeof CoverageLevel];
33
33
  declare const LedgerSchema: z.ZodObject<{
34
34
  routes: z.ZodObject<{
35
- discovered: z.ZodArray<z.ZodString, "many">;
36
- reached: z.ZodArray<z.ZodString, "many">;
37
- }, "strip", z.ZodTypeAny, {
38
- reached: string[];
39
- discovered: string[];
40
- }, {
41
- reached: string[];
42
- discovered: string[];
43
- }>;
35
+ discovered: z.ZodArray<z.ZodString>;
36
+ reached: z.ZodArray<z.ZodString>;
37
+ }, z.core.$strip>;
44
38
  controls: z.ZodObject<{
45
- seen: z.ZodArray<z.ZodString, "many">;
46
- touched: z.ZodArray<z.ZodString, "many">;
47
- proved: z.ZodArray<z.ZodString, "many">;
48
- }, "strip", z.ZodTypeAny, {
49
- proved: string[];
50
- seen: string[];
51
- touched: string[];
52
- }, {
53
- proved: string[];
54
- seen: string[];
55
- touched: string[];
56
- }>;
39
+ seen: z.ZodArray<z.ZodString>;
40
+ touched: z.ZodArray<z.ZodString>;
41
+ proved: z.ZodArray<z.ZodString>;
42
+ }, z.core.$strip>;
57
43
  journeys: z.ZodObject<{
58
- completed: z.ZodArray<z.ZodString, "many">;
59
- }, "strip", z.ZodTypeAny, {
60
- completed: string[];
61
- }, {
62
- completed: string[];
63
- }>;
44
+ completed: z.ZodArray<z.ZodString>;
45
+ }, z.core.$strip>;
64
46
  writes: z.ZodObject<{
65
- seen: z.ZodArray<z.ZodString, "many">;
66
- branched: z.ZodArray<z.ZodString, "many">;
67
- unhandled: z.ZodArray<z.ZodString, "many">;
68
- }, "strip", z.ZodTypeAny, {
69
- seen: string[];
70
- branched: string[];
71
- unhandled: string[];
72
- }, {
73
- seen: string[];
74
- branched: string[];
75
- unhandled: string[];
76
- }>;
47
+ seen: z.ZodArray<z.ZodString>;
48
+ branched: z.ZodArray<z.ZodString>;
49
+ unhandled: z.ZodArray<z.ZodString>;
50
+ }, z.core.$strip>;
77
51
  code: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
78
52
  name: z.ZodString;
79
53
  executed: z.ZodBoolean;
80
- }, "strip", z.ZodTypeAny, {
81
- name: string;
82
- executed: boolean;
83
- }, {
84
- name: string;
85
- executed: boolean;
86
- }>>>;
87
- /** Per file, when its code coverage was last taken. Absent in a ledger from before it existed. */
54
+ }, z.core.$strip>>>;
88
55
  codeTaken: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
89
56
  best: z.ZodRecord<z.ZodString, z.ZodNumber>;
90
- }, "strip", z.ZodTypeAny, {
91
- code: Record<string, Record<string, {
92
- name: string;
93
- executed: boolean;
94
- }>>;
95
- journeys: {
96
- completed: string[];
97
- };
98
- best: Record<string, number>;
99
- writes: {
100
- seen: string[];
101
- branched: string[];
102
- unhandled: string[];
103
- };
104
- routes: {
105
- reached: string[];
106
- discovered: string[];
107
- };
108
- controls: {
109
- proved: string[];
110
- seen: string[];
111
- touched: string[];
112
- };
113
- codeTaken: Record<string, number>;
114
- }, {
115
- code: Record<string, Record<string, {
116
- name: string;
117
- executed: boolean;
118
- }>>;
119
- journeys: {
120
- completed: string[];
121
- };
122
- best: Record<string, number>;
123
- writes: {
124
- seen: string[];
125
- branched: string[];
126
- unhandled: string[];
127
- };
128
- routes: {
129
- reached: string[];
130
- discovered: string[];
131
- };
132
- controls: {
133
- proved: string[];
134
- seen: string[];
135
- touched: string[];
136
- };
137
- codeTaken?: Record<string, number> | undefined;
138
- }>;
57
+ }, z.core.$strip>;
139
58
  export type AppLedger = z.infer<typeof LedgerSchema>;
140
59
  /** What one producer contributes. Every part optional: a drive adds what it saw and nothing else. */
141
60
  export interface LedgerDelta {
@@ -41,10 +41,10 @@ const LedgerSchema = z.object({
41
41
  controls: z.object({ seen: names, touched: names, proved: names }),
42
42
  journeys: z.object({ completed: names }),
43
43
  writes: z.object({ seen: names, branched: names, unhandled: names }),
44
- code: z.record(z.record(z.object({ name: z.string(), executed: z.boolean() }))),
44
+ code: z.record(z.string(), z.record(z.string(), z.object({ name: z.string(), executed: z.boolean() }))),
45
45
  /** Per file, when its code coverage was last taken. Absent in a ledger from before it existed. */
46
- codeTaken: z.record(z.number()).default({}),
47
- best: z.record(z.number()),
46
+ codeTaken: z.record(z.string(), z.number()).default({}),
47
+ best: z.record(z.string(), z.number()),
48
48
  });
49
49
  /** A ledger file's text, or an empty ledger for anything unreadable: coverage never throws. */
50
50
  function parseLedger(text) {
@@ -37,20 +37,8 @@ declare const LicensePayloadSchema: z.ZodObject<{
37
37
  org: z.ZodString;
38
38
  plan: z.ZodString;
39
39
  exp: z.ZodNumber;
40
- features: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
41
- }, "strip", z.ZodTypeAny, {
42
- plan: string;
43
- lid: string;
44
- org: string;
45
- exp: number;
46
- features?: string[] | undefined;
47
- }, {
48
- plan: string;
49
- lid: string;
50
- org: string;
51
- exp: number;
52
- features?: string[] | undefined;
53
- }>;
40
+ features: z.ZodOptional<z.ZodArray<z.ZodString>>;
41
+ }, z.core.$strip>;
54
42
  export type LicensePayload = z.infer<typeof LicensePayloadSchema>;
55
43
  type LicenseCheck = {
56
44
  status: typeof LicenseStatus.VALID;
@@ -49,12 +49,20 @@ const MAX_IN_FLIGHT = 8;
49
49
  let inFlight = 0;
50
50
  const reported = new Set();
51
51
  function reportOnce(key, message) {
52
- if (reported.has(key))
52
+ if (reported.has(key)) {
53
53
  return;
54
+ }
54
55
  reported.add(key);
55
56
  log(`hook: ${message}`);
56
57
  }
57
- /** Cached config plus the mtime it was read at, so an edit takes effect without a daemon restart. */
58
+ /**
59
+ * Cached config plus the file and mtime it was read at, so an edit takes effect without a
60
+ * daemon restart.
61
+ *
62
+ * The path is part of the key. Two roots can share an mtime — a coarse filesystem stamps
63
+ * every write in the same millisecond — and a cache that remembers only the time returns
64
+ * the other root's commands.
65
+ */
58
66
  let cached;
59
67
  /**
60
68
  * Read `.reticle/hooks.json`, or nothing.
@@ -76,21 +84,22 @@ export function readHookConfig(reticleRoot) {
76
84
  return {};
77
85
  }
78
86
  try {
79
- return readOpenHookConfig(fd);
87
+ return readOpenHookConfig(fd, path);
80
88
  }
81
89
  finally {
82
90
  closeSync(fd);
83
91
  }
84
92
  }
85
- function readOpenHookConfig(fd) {
93
+ function readOpenHookConfig(fd, path) {
86
94
  const mtime = fstatSync(fd).mtimeMs;
87
- if (cached !== undefined && cached.at === mtime)
95
+ if (cached !== undefined && cached.path === path && cached.at === mtime) {
88
96
  return cached.config;
97
+ }
89
98
  try {
90
99
  const parsed = HookConfigSchema.safeParse(JSON.parse(readFileSync(fd, 'utf8')));
91
100
  if (!parsed.success) {
92
101
  reportOnce(`config:${String(mtime)}`, `${ReticleDir.HOOKS_FILE} is not a map of event→command`);
93
- cached = { at: mtime, config: {} };
102
+ cached = { path, at: mtime, config: {} };
94
103
  return {};
95
104
  }
96
105
  // An unknown event name is the likeliest mistake in this file and the one that is otherwise
@@ -103,12 +112,12 @@ function readOpenHookConfig(fd) {
103
112
  }
104
113
  reportOnce(`unknown:${event}`, `${ReticleDir.HOOKS_FILE} names "${event}", which is not a Reticle event. Known events: ${HOOK_EVENT_NAMES.join(', ')}`);
105
114
  }
106
- cached = { at: mtime, config: known };
115
+ cached = { path, at: mtime, config: known };
107
116
  return known;
108
117
  }
109
118
  catch (error) {
110
119
  reportOnce(`parse:${String(mtime)}`, `${ReticleDir.HOOKS_FILE} could not be read: ${error instanceof Error ? error.message : String(error)}`);
111
- cached = { at: mtime, config: {} };
120
+ cached = { path, at: mtime, config: {} };
112
121
  return {};
113
122
  }
114
123
  }
@@ -167,8 +176,9 @@ export function runHookCommand(command, payload, cwd) {
167
176
  export function installCommandHooks(reticleRoot) {
168
177
  return onHook(undefined, (payload) => {
169
178
  const command = readHookConfig(reticleRoot)[payload.event];
170
- if (command === undefined || 0 === command.trim().length)
179
+ if (command === undefined || 0 === command.trim().length) {
171
180
  return;
181
+ }
172
182
  runHookCommand(command, payload, reticleRoot);
173
183
  });
174
184
  }
@@ -18,52 +18,15 @@ export declare const CAPSULE_VERSION = 1;
18
18
  declare const CapsuleSchema: z.ZodObject<{
19
19
  version: z.ZodLiteral<1>;
20
20
  id: z.ZodString;
21
- /** The flow the failure happened in, when it came from one (a bare assert has no flow). */
22
21
  flow: z.ZodOptional<z.ZodString>;
23
22
  createdAt: z.ZodNumber;
24
- /** Why it was captured — a failed assertion, a crawl anomaly, a human review pin. */
25
23
  origin: z.ZodString;
26
- /** The consequence that was supposed to hold. */
27
24
  expected: z.ZodString;
28
- /** What was observed instead — the first-divergence sentence. */
29
25
  observed: z.ZodString;
30
- /** Minimal steps to reproduce (prefix-trimmed). */
31
- steps: z.ZodArray<z.ZodType<FlowStep, z.ZodTypeDef, FlowStep>, "many">;
32
- /**
33
- * How many times this exact failure has been captured, and when it last was.
34
- *
35
- * A repeat is not a new capsule -- it is the same one, again. "This failed 37 times, most
36
- * recently at T" is strictly MORE than 37 files each holding one timestamp, so folding the
37
- * duplicates in adds information rather than discarding it.
38
- *
39
- * Optional, because a capsule written before these existed has neither and must not be read as
40
- * though it were seen once when nobody counted.
41
- */
26
+ steps: z.ZodArray<z.ZodType<FlowStep, unknown, z.core.$ZodTypeInternals<FlowStep, unknown>>>;
42
27
  seen: z.ZodOptional<z.ZodNumber>;
43
28
  lastSeenAt: z.ZodOptional<z.ZodNumber>;
44
- }, "strip", z.ZodTypeAny, {
45
- version: 1;
46
- id: string;
47
- origin: string;
48
- expected: string;
49
- createdAt: number;
50
- steps: FlowStep[];
51
- observed: string;
52
- flow?: string | undefined;
53
- seen?: number | undefined;
54
- lastSeenAt?: number | undefined;
55
- }, {
56
- version: 1;
57
- id: string;
58
- origin: string;
59
- expected: string;
60
- createdAt: number;
61
- steps: FlowStep[];
62
- observed: string;
63
- flow?: string | undefined;
64
- seen?: number | undefined;
65
- lastSeenAt?: number | undefined;
66
- }>;
29
+ }, z.core.$strip>;
67
30
  export type Capsule = z.infer<typeof CapsuleSchema>;
68
31
  /** Capsule ids are filenames — refuse anything that could escape the capsules directory. */
69
32
  export declare function isValidCapsuleId(id: string): boolean;
@@ -85,7 +85,7 @@ export const ANNOTATE_TOOLS = [
85
85
  .optional()
86
86
  .describe('Treat statePath as an INVARIANT that must still hold AFTER the action settles (a blast-radius "this unrelated path must not have moved" check), not a condition to wait for. Without it a wait-until-true read passes before an over-reaching side-effect lands.'),
87
87
  dataMatches: z
88
- .record(z.unknown())
88
+ .record(z.string(), z.unknown())
89
89
  .optional()
90
90
  .describe('Key/value pairs the signal payload must match (assert-signal only).'),
91
91
  net: z
@@ -55,7 +55,7 @@ export const secretEnvKey = (field) => `${SECRET_ENV_PREFIX}${field.replace(/[^a
55
55
  /** The schema of that argument, shared by every tool that replays a flow. */
56
56
  export { CALL_SECRETS_ARG };
57
57
  export const callSecretsArg = z
58
- .record(z.string())
58
+ .record(z.string(), z.string())
59
59
  .optional()
60
60
  .describe('Redacted fields\' values, this call only, never kept: { "auth-password": "…" }');
61
61
  /**
@@ -124,15 +124,22 @@ export function coerceFlowFileExpects(raw) {
124
124
  * The issues a union rejection buries.
125
125
  *
126
126
  * `expect` accepts a predicate OR a v1 expect, and zod reports a union failure as one
127
- * `invalid_union` issue whose branch errors live inside `unionErrors`. The one that matters is the
127
+ * `invalid_union` issue whose branch errors live inside `errors`, with paths relative to the union. The one that matters is the
128
128
  * v1 branch's `unrecognized_keys` — the misspelled field. Without this the file was still correctly
129
129
  * REFUSED and the message said only "malformed", which sends the author back to hunt a
130
130
  * one-character typo we had already identified.
131
131
  */
132
132
  function flatten(issues) {
133
- return issues.flatMap((issue) => 'invalid_union' === issue.code
134
- ? [issue, ...flatten(issue.unionErrors.flatMap((e) => e.issues))]
135
- : [issue]);
133
+ return issues.flatMap((issue) => {
134
+ if ('invalid_union' !== issue.code) {
135
+ return [issue];
136
+ }
137
+ const branches = issue.errors.flat().map((inner) => ({
138
+ ...inner,
139
+ path: [...issue.path, ...inner.path],
140
+ }));
141
+ return [issue, ...flatten(branches)];
142
+ });
136
143
  }
137
144
  export function describeFlowZodFailure(error) {
138
145
  const all = flatten(error.issues);
@@ -145,12 +152,13 @@ export function describeFlowZodFailure(error) {
145
152
  * top-level `invalid_union` says only "malformed", which is true and useless.
146
153
  */
147
154
  const named = (one) => {
148
- // NOT the discriminator complaint. `invalid_union_discriminator` on `expect.kind` is the
155
+ // NOT the discriminator complaint. An `invalid_union` with a `discriminator` on `expect.kind` is the
149
156
  // PREDICATE branch objecting that a v1 expect has no `kind` — which is true of every v1 expect
150
157
  // ever written and tells the author nothing. The useful issue is the one from the branch they
151
158
  // were actually writing.
152
- if ('invalid_union_discriminator' === one.code)
159
+ if ('invalid_union' === one.code && undefined !== one.discriminator) {
153
160
  return false;
161
+ }
154
162
  return (('steps' === one.path[0] && 'expect' === one.path[2] && one.path.length > 3) ||
155
163
  ('success' === one.path[0] && one.path.length > 1));
156
164
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What a flow needs from the live session to run in a leased context, and what it reports when the
3
+ * lease never came up.
4
+ */
5
+ import { type FlowReplayResult } from '@reticlehq/core';
6
+ import type { ToolDeps } from '../../surface/tools/tool-kit.js';
7
+ /**
8
+ * The URL a leased context should open: the app's ORIGIN, not the live tab's current location.
9
+ *
10
+ * The live tab drifts — every replay navigates it — so using its full URL made each lease start wherever
11
+ * the previous run happened to finish (observed: leases opening /deployments and failing to find the
12
+ * login control that only exists at the root). A lease must be a FRESH visit to a deterministic entry
13
+ * point; flows that need another page carry their own startPath.
14
+ */
15
+ export declare function leasableAppUrl(deps: ToolDeps, sessionId: string | undefined): string | undefined;
16
+ /**
17
+ * The live session's own URL — what the fixture port must be looked up by.
18
+ *
19
+ * NOT `leasableAppUrl`. That returns an ORIGIN, and the input provider finds a page by comparing
20
+ * full URLs, so an origin matched no page: `suiteFixtureSeed` answered `undefined` on every run and
21
+ * every leased flow booted cold while the code read as if it were seeded. Silent, because every
22
+ * refusal on that path is deliberately silent — a fixture is an optimisation, and a suite must not
23
+ * die when one is unavailable.
24
+ */
25
+ export declare function sessionUrlOf(deps: ToolDeps, sessionId: string | undefined): string | undefined;
26
+ /** A flow whose leased context never came up is a suite ERROR, never a silent pass. */
27
+ export declare function leaseFailureReplay(name: string, error: string | undefined): FlowReplayResult;