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
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Seeded randomness and the human-like scroll schedule for `browse --collect`.
3
+ *
4
+ * Everything here is pure: given a seed and a pacing policy, the plan for each
5
+ * step (wheel notches, pauses, reading pauses, small scroll-backs) is fully
6
+ * reproducible, which is what the unit tests rely on.
7
+ */
8
+
9
+ /** A random source returning floats in [0, 1). */
10
+ export type Rng = () => number;
11
+
12
+ /** mulberry32: a small, fast, well-distributed 32-bit seeded generator. */
13
+ export function createRng(seed: number): Rng {
14
+ let a = seed >>> 0;
15
+ return () => {
16
+ a = (a + 0x6d2b79f5) >>> 0;
17
+ let t = a;
18
+ t = Math.imul(t ^ (t >>> 15), t | 1);
19
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
20
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
21
+ };
22
+ }
23
+
24
+ /** A fresh 32-bit seed for runs that did not pass one. */
25
+ export function randomSeed(): number {
26
+ return Math.floor(Math.random() * 0x1_0000_0000) >>> 0;
27
+ }
28
+
29
+ export interface Range {
30
+ min: number;
31
+ max: number;
32
+ }
33
+
34
+ export function uniform(rng: Rng, range: Range): number {
35
+ return range.min + (range.max - range.min) * rng();
36
+ }
37
+
38
+ export interface ScrollPacing {
39
+ /** Scroll distance per step, as a fraction of the viewport height. */
40
+ step: Range;
41
+ /** Pause after each step, in ms. */
42
+ pauseMs: Range;
43
+ /** Probability that a step is followed by a longer reading pause instead. */
44
+ readChance: number;
45
+ /** Reading pause, in ms. */
46
+ readPauseMs: Range;
47
+ /** Probability that a step starts with a small scroll back up. */
48
+ backChance: number;
49
+ /** Scroll-back distance, as a fraction of the viewport height. */
50
+ back: Range;
51
+ /** Size of one wheel notch, in px. */
52
+ tickPx: Range;
53
+ /** Gap between wheel notches, in ms. */
54
+ tickGapMs: Range;
55
+ }
56
+
57
+ export const DEFAULT_SCROLL_PACING: ScrollPacing = {
58
+ step: { min: 0.6, max: 1.1 },
59
+ pauseMs: { min: 1_200, max: 3_500 },
60
+ readChance: 0.12,
61
+ readPauseMs: { min: 4_000, max: 9_000 },
62
+ backChance: 0.08,
63
+ back: { min: 0.1, max: 0.3 },
64
+ tickPx: { min: 80, max: 140 },
65
+ tickGapMs: { min: 12, max: 45 },
66
+ };
67
+
68
+ /** Upper bounds that keep every pause and step bounded. */
69
+ export const PACING_LIMITS = {
70
+ maxStepFraction: 3,
71
+ maxPauseMs: 120_000,
72
+ } as const;
73
+
74
+ export interface WheelTick {
75
+ /** Positive scrolls down, negative scrolls up. */
76
+ deltaY: number;
77
+ /** Wait after this notch, in ms. */
78
+ gapMs: number;
79
+ }
80
+
81
+ export interface ScrollStepPlan {
82
+ /** Optional scroll-back notches run before the main step (negative deltas). */
83
+ back: WheelTick[];
84
+ /** Pause after the scroll-back, in ms (0 when there is no scroll-back). */
85
+ backPauseMs: number;
86
+ /** Main downward notches. */
87
+ ticks: WheelTick[];
88
+ /** Total downward distance, in px. */
89
+ deltaY: number;
90
+ /** Pause after the main step, in ms. */
91
+ pauseMs: number;
92
+ /** True when the pause is a longer reading pause. */
93
+ reading: boolean;
94
+ }
95
+
96
+ /** Split a distance into wheel notches the way a mouse wheel reports them. */
97
+ export function wheelTicks(rng: Rng, pacing: ScrollPacing, distance: number): WheelTick[] {
98
+ const sign = distance < 0 ? -1 : 1;
99
+ let remaining = Math.round(Math.abs(distance));
100
+ const ticks: WheelTick[] = [];
101
+ while (remaining > 0) {
102
+ const size = Math.min(remaining, Math.max(1, Math.round(uniform(rng, pacing.tickPx))));
103
+ remaining -= size;
104
+ ticks.push({ deltaY: sign * size, gapMs: Math.round(uniform(rng, pacing.tickGapMs)) });
105
+ }
106
+ return ticks;
107
+ }
108
+
109
+ export function planScrollStep(
110
+ rng: Rng,
111
+ pacing: ScrollPacing,
112
+ viewportHeight: number,
113
+ ): ScrollStepPlan {
114
+ const height = Math.max(1, viewportHeight);
115
+ let back: WheelTick[] = [];
116
+ let backPauseMs = 0;
117
+ if (rng() < pacing.backChance) {
118
+ back = wheelTicks(rng, pacing, -uniform(rng, pacing.back) * height);
119
+ backPauseMs = Math.round(uniform(rng, { min: 400, max: 1_100 }));
120
+ }
121
+ const deltaY = Math.max(1, Math.round(uniform(rng, pacing.step) * height));
122
+ const ticks = wheelTicks(rng, pacing, deltaY);
123
+ const reading = rng() < pacing.readChance;
124
+ const pauseMs = Math.round(uniform(rng, reading ? pacing.readPauseMs : pacing.pauseMs));
125
+ return { back, backPauseMs, ticks, deltaY, pauseMs, reading };
126
+ }
127
+
128
+ /**
129
+ * Parse `min-max` (or a single value meaning min = max). Throws a message that
130
+ * names the flag when the value is malformed or out of bounds.
131
+ */
132
+ export function parseRange(raw: string, flag: string, bounds: { min: number; max: number }): Range {
133
+ const text = raw.trim();
134
+ const m = /^(\d+(?:\.\d+)?)(?:\s*-\s*(\d+(?:\.\d+)?))?$/.exec(text);
135
+ if (!m) throw new Error(`${flag} must be <min>-<max> or a single number (got "${raw}").`);
136
+ const min = Number(m[1]);
137
+ const max = m[2] === undefined ? min : Number(m[2]);
138
+ if (min > max) throw new Error(`${flag}: min (${min}) must not exceed max (${max}).`);
139
+ if (min < bounds.min || max > bounds.max) {
140
+ throw new Error(`${flag} must stay within ${bounds.min}-${bounds.max} (got "${raw}").`);
141
+ }
142
+ return { min, max };
143
+ }
144
+
145
+ export function parseProbability(raw: string, flag: string): number {
146
+ const n = Number(raw);
147
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
148
+ throw new Error(`${flag} must be a number from 0 to 1 (got "${raw}").`);
149
+ }
150
+ return n;
151
+ }
152
+
153
+ /**
154
+ * Machine-wide pacing defaults from the environment, in the same style as the
155
+ * page-load pace gate (`HARNERY_PACE_*`):
156
+ *
157
+ * - `HARNERY_COLLECT_PAUSE_MS`: pause after each step, `min-max` in ms.
158
+ * - `HARNERY_COLLECT_STEP`: step size as a viewport fraction, `min-max`.
159
+ *
160
+ * Command flags override these.
161
+ */
162
+ export function scrollPacingFromEnv(env: NodeJS.ProcessEnv = process.env): ScrollPacing {
163
+ const pacing: ScrollPacing = { ...DEFAULT_SCROLL_PACING };
164
+ const pause = env.HARNERY_COLLECT_PAUSE_MS?.trim();
165
+ if (pause) {
166
+ pacing.pauseMs = parseRange(pause, "HARNERY_COLLECT_PAUSE_MS", {
167
+ min: 0,
168
+ max: PACING_LIMITS.maxPauseMs,
169
+ });
170
+ }
171
+ const step = env.HARNERY_COLLECT_STEP?.trim();
172
+ if (step) {
173
+ pacing.step = parseRange(step, "HARNERY_COLLECT_STEP", {
174
+ min: 0.05,
175
+ max: PACING_LIMITS.maxStepFraction,
176
+ });
177
+ }
178
+ return pacing;
179
+ }
@@ -185,6 +185,7 @@ export {
185
185
  type QaRunContext,
186
186
  type QaRunCritiqueOutcome,
187
187
  type QaRunCritiquePool,
188
+ type QaRunCritiqueTileTiming,
188
189
  type QaRunInteraction,
189
190
  type QaRunJob,
190
191
  type QaRunJobValidation,
@@ -25,6 +25,7 @@ import {
25
25
  } from "./page-review-pack.js";
26
26
  import type { QaContext } from "./qa-plan.js";
27
27
  import type { CritiqueReusePlan } from "./qa-reuse.js";
28
+ import type { QaRunCritiqueTileTiming } from "./qa-run-contracts.js";
28
29
  import { loadQaSnapshot, type QaSnapshotStoreOptions } from "./qa-snapshot.js";
29
30
 
30
31
  export interface JudgePageReviewPackOptions {
@@ -62,6 +63,7 @@ export interface JudgedContext extends PageReviewCritiqueRecord {
62
63
  export interface JudgePageReviewPackResult {
63
64
  contexts: JudgedContext[];
64
65
  pool: { concurrency: number; wall_time_ms: number; provider: string };
66
+ tile_timings: QaRunCritiqueTileTiming[];
65
67
  tiles_total: number;
66
68
  tiles_reviewed: number;
67
69
  tiles_reused: number;
@@ -261,6 +263,7 @@ export async function judgePageReviewPack(
261
263
 
262
264
  let meta: Record<string, unknown> | undefined;
263
265
  let concurrency = 0;
266
+ const tileTimings: Array<QaRunCritiqueTileTiming | undefined> = new Array(work.length);
264
267
  if (provider && work.length > 0) {
265
268
  concurrency = Math.max(
266
269
  1,
@@ -277,12 +280,15 @@ export async function judgePageReviewPack(
277
280
  const entry = perContext[item.contextIndex];
278
281
  if (!entry) continue;
279
282
  entry.judged.add(item.tile.id);
283
+ const tileStarted = performance.now();
284
+ let tileOutcome: QaRunCritiqueTileTiming["outcome"] = "passed";
280
285
  try {
281
286
  const found = await provider({ url: item.url, rubric: item.rubric, tile: item.tile });
282
287
  for (const finding of found) {
283
288
  entry.findings.push({ ...finding, tile: item.tile.index, tile_id: item.tile.id });
284
289
  }
285
290
  } catch (err: unknown) {
291
+ tileOutcome = "failed";
286
292
  entry.findings.push({
287
293
  tile: item.tile.index,
288
294
  tile_id: item.tile.id,
@@ -290,6 +296,13 @@ export async function judgePageReviewPack(
290
296
  category: "provider-error",
291
297
  description: `critique provider failed on ${item.tile.label}: ${err instanceof Error ? err.message : String(err)}`,
292
298
  });
299
+ } finally {
300
+ tileTimings[i] = {
301
+ context_id: entry.record.id,
302
+ tile_id: item.tile.id,
303
+ duration_ms: Math.max(0, Math.round(performance.now() - tileStarted)),
304
+ outcome: tileOutcome,
305
+ };
293
306
  }
294
307
  }
295
308
  };
@@ -360,6 +373,9 @@ export async function judgePageReviewPack(
360
373
  return {
361
374
  contexts,
362
375
  pool: { concurrency, wall_time_ms: Date.now() - started, provider: label },
376
+ tile_timings: tileTimings.filter(
377
+ (timing): timing is QaRunCritiqueTileTiming => timing !== undefined,
378
+ ),
363
379
  tiles_total: tilesTotal,
364
380
  tiles_reviewed: tilesReviewed,
365
381
  tiles_reused: tilesReused,
@@ -145,6 +145,15 @@ export interface QaRunCritiqueLatency {
145
145
  p95: number;
146
146
  }
147
147
 
148
+ /** Wall time for one tile's complete vision call, including provider retries
149
+ * and failover. Reused and unjudged tiles have no call and no timing entry. */
150
+ export interface QaRunCritiqueTileTiming {
151
+ context_id: string;
152
+ tile_id: string;
153
+ duration_ms: number;
154
+ outcome: "passed" | "failed";
155
+ }
156
+
148
157
  /** What share of the page the critique tiles covered, lifted from the browse
149
158
  * envelope. `capped` means the tiler dropped bands past its per-context
150
159
  * maximum; in signoff mode that is a blocker, in review mode a flag. Across
@@ -188,6 +197,8 @@ export interface QaRunCritiquePool {
188
197
  wall_time_ms: number;
189
198
  provider: string;
190
199
  latency_ms?: Record<string, QaRunCritiqueLatency>;
200
+ /** One entry per attempted tile, in context and tile order. */
201
+ tile_timings?: QaRunCritiqueTileTiming[];
191
202
  }
192
203
 
193
204
  /** Where the run's page review pack lives: the on-disk evidence an agent can
@@ -1397,6 +1397,7 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
1397
1397
  wall_time_ms: judged.pool.wall_time_ms,
1398
1398
  provider: judged.pool.provider,
1399
1399
  ...(latency ? { latency_ms: latency } : {}),
1400
+ tile_timings: judged.tile_timings,
1400
1401
  };
1401
1402
  for (const row of judged.contexts) {
1402
1403
  judgedById.set(row.context_id, row);
@@ -0,0 +1,227 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { Cookie } from "../cookies/client.ts";
6
+
7
+ /**
8
+ * Sign-in through the Windows host's Google Chrome when running under WSL.
9
+ *
10
+ * Some sign-in flows (X is the reported case) reject every browser running
11
+ * inside WSL, including a plain Linux Google Chrome with nothing attached:
12
+ * the sign-in request comes back "429 Too Many Requests" while the same
13
+ * account signs in from the Windows desktop browser without trouble. The
14
+ * Windows Chrome is the client those flows accept, so `browse --login
15
+ * --plain` under WSL opens it on a dedicated Windows-side profile. The
16
+ * person signs in there with nothing attached. Afterwards the profile is
17
+ * reopened headless for a moment, its cookies for the sign-in site are read
18
+ * through the DevTools protocol, and they are merged into the shared cookie
19
+ * store that later `browse` runs attach. The Windows profile cannot be shared
20
+ * with Linux Chromium directly because Windows encrypts its cookie database
21
+ * with keys only Windows can read.
22
+ *
23
+ * Under WSL2's default NAT networking the Windows loopback port is not
24
+ * reachable from Linux, so the DevTools conversation runs inside a short
25
+ * PowerShell script on the Windows side and prints the result to stdout.
26
+ */
27
+
28
+ const WINDOWS_CHROME_CANDIDATES = [
29
+ "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
30
+ "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
31
+ ];
32
+
33
+ /** Linux path of the Windows host's Google Chrome, or `undefined`. */
34
+ export function windowsChromePath(
35
+ exists: (path: string) => boolean = existsSync,
36
+ localAppDataLinux?: string,
37
+ ): string | undefined {
38
+ const candidates = [...WINDOWS_CHROME_CANDIDATES];
39
+ if (localAppDataLinux)
40
+ candidates.push(join(localAppDataLinux, "Google", "Chrome", "Application", "chrome.exe"));
41
+ return candidates.find((candidate) => exists(candidate));
42
+ }
43
+
44
+ /** `%LOCALAPPDATA%` as a Windows path, read through cmd.exe interop. */
45
+ export function windowsLocalAppData(): string | undefined {
46
+ const result = spawnSync("cmd.exe", ["/c", "echo %LOCALAPPDATA%"], {
47
+ encoding: "utf8",
48
+ timeout: 10_000,
49
+ });
50
+ const value = (result.stdout ?? "").replace(/\r/g, "").trim();
51
+ return result.status === 0 && /^[A-Za-z]:\\/.test(value) ? value : undefined;
52
+ }
53
+
54
+ /** Windows-side profile used only for `--login --plain` sign-ins under WSL. */
55
+ export function windowsPlainProfile(localAppData: string): string {
56
+ return `${localAppData}\\harnery\\plain-login-profile`;
57
+ }
58
+
59
+ /**
60
+ * The registrable domain to harvest for a sign-in URL: the last two labels of
61
+ * the host (`x.com` from `https://x.com/i/flow/login`). Cookies are kept when
62
+ * their domain equals it or is a subdomain of it, so a sign-in that also sets
63
+ * cookies for an identity provider does not copy those into the store.
64
+ */
65
+ export function harvestDomain(url: string): string {
66
+ const host = new URL(url).hostname.toLowerCase();
67
+ const labels = host.split(".").filter(Boolean);
68
+ return labels.length <= 2 ? host : labels.slice(-2).join(".");
69
+ }
70
+
71
+ export function cookieInDomain(cookieDomain: string, domain: string): boolean {
72
+ const bare = cookieDomain.replace(/^\./, "").toLowerCase();
73
+ return bare === domain || bare.endsWith(`.${domain}`);
74
+ }
75
+
76
+ /** Shape `Storage.getCookies` returns; extra fields are ignored. */
77
+ interface CdpCookie {
78
+ name: string;
79
+ value: string;
80
+ domain: string;
81
+ path: string;
82
+ expires: number;
83
+ size?: number;
84
+ httpOnly: boolean;
85
+ secure: boolean;
86
+ session?: boolean;
87
+ sameSite?: string;
88
+ }
89
+
90
+ /** Keep the sign-in site's cookies and normalize them to the store's shape. */
91
+ export function cookiesForDomain(cdpCookies: CdpCookie[], domain: string): Cookie[] {
92
+ return cdpCookies
93
+ .filter((cookie) => cookieInDomain(cookie.domain, domain))
94
+ .map((cookie) => ({
95
+ name: cookie.name,
96
+ value: cookie.value,
97
+ domain: cookie.domain,
98
+ path: cookie.path || "/",
99
+ expires: typeof cookie.expires === "number" ? cookie.expires : -1,
100
+ ...(cookie.size === undefined ? {} : { size: cookie.size }),
101
+ httpOnly: Boolean(cookie.httpOnly),
102
+ secure: Boolean(cookie.secure),
103
+ ...(cookie.session === undefined ? {} : { session: cookie.session }),
104
+ ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}),
105
+ }));
106
+ }
107
+
108
+ /**
109
+ * PowerShell that does the whole read on the Windows side: start Chrome
110
+ * headless on the profile, wait for the DevToolsActivePort file it writes,
111
+ * read every cookie with `Storage.getCookies`, write the raw response to
112
+ * stdout, and close the browser. WSL never touches the profile folder, which
113
+ * it may not be able to list. Every wait carries a deadline.
114
+ */
115
+ export function harvestScript(): string {
116
+ return [
117
+ "param([string]$Chrome, [string]$Profile, [int]$StartupSeconds = 30)",
118
+ "$ErrorActionPreference = 'Stop'",
119
+ "$portFile = Join-Path $Profile 'DevToolsActivePort'",
120
+ "if (Test-Path $portFile) { Remove-Item $portFile -Force }",
121
+ "$args = @('--headless=new', '--no-first-run', '--no-default-browser-check', '--remote-debugging-port=0', \"--user-data-dir=$Profile\", 'about:blank')",
122
+ "$proc = Start-Process -FilePath $Chrome -ArgumentList $args -PassThru -WindowStyle Hidden",
123
+ "$deadline = (Get-Date).AddSeconds($StartupSeconds)",
124
+ "while (-not (Test-Path $portFile)) { if ((Get-Date) -gt $deadline) { throw 'Windows Chrome did not open its DevTools port in time.' }; if ($proc.HasExited) { throw 'Windows Chrome exited before opening its DevTools port.' }; Start-Sleep -Milliseconds 250 }",
125
+ "Start-Sleep -Milliseconds 200",
126
+ "$lines = Get-Content $portFile",
127
+ "$port = [int]$lines[0]; $path = $lines[1]",
128
+ "$ws = New-Object System.Net.WebSockets.ClientWebSocket",
129
+ "$cts = New-Object System.Threading.CancellationTokenSource 20000",
130
+ '$ws.ConnectAsync([Uri]("ws://127.0.0.1:$port$path"), $cts.Token).Wait()',
131
+ "function Send($obj) { $b = [Text.Encoding]::UTF8.GetBytes(($obj | ConvertTo-Json -Compress)); $ws.SendAsync([ArraySegment[byte]]$b, 'Text', $true, $cts.Token).Wait() }",
132
+ "function Recv() { $buf = New-Object byte[] 65536; $ms = New-Object IO.MemoryStream; do { $r = $ws.ReceiveAsync([ArraySegment[byte]]$buf, $cts.Token).Result; $ms.Write($buf, 0, $r.Count) } while (-not $r.EndOfMessage); [Text.Encoding]::UTF8.GetString($ms.ToArray()) }",
133
+ "Send @{ id = 1; method = 'Storage.getCookies' }",
134
+ "$out = $null",
135
+ "while ($null -eq $out) { $m = Recv; if ($m -match '\"id\":1[,}]') { $out = $m } }",
136
+ "[Console]::Out.Write($out)",
137
+ "try { Send @{ id = 2; method = 'Browser.close' } } catch {}",
138
+ "if (-not $proc.WaitForExit(10000)) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }",
139
+ ].join("\r\n");
140
+ }
141
+
142
+ /**
143
+ * Reopen the Windows profile headless, read its cookies for `domain`, and
144
+ * close it. Returns the cookies in the store's shape.
145
+ */
146
+ export function harvestWindowsChromeCookies(input: {
147
+ chromeWindows: string;
148
+ profileWindows: string;
149
+ domain: string;
150
+ startupTimeoutSeconds?: number;
151
+ }): Cookie[] {
152
+ const scriptDir = join(tmpdir(), "harnery-windows-chrome");
153
+ mkdirSync(scriptDir, { recursive: true });
154
+ const scriptLinux = join(scriptDir, `harvest-cookies-${process.pid}.ps1`);
155
+ writeFileSync(scriptLinux, harvestScript(), { mode: 0o600 });
156
+ try {
157
+ const scriptWindows = spawnSync("wslpath", ["-w", scriptLinux], {
158
+ encoding: "utf8",
159
+ }).stdout.trim();
160
+ const result = spawnSync(
161
+ "powershell.exe",
162
+ [
163
+ "-NoProfile",
164
+ "-ExecutionPolicy",
165
+ "Bypass",
166
+ "-File",
167
+ scriptWindows,
168
+ "-Chrome",
169
+ input.chromeWindows,
170
+ "-Profile",
171
+ input.profileWindows,
172
+ "-StartupSeconds",
173
+ String(input.startupTimeoutSeconds ?? 30),
174
+ ],
175
+ { encoding: "utf8", timeout: 90_000, maxBuffer: 64 * 1024 * 1024 },
176
+ );
177
+ if (result.status !== 0) {
178
+ throw new Error(
179
+ `Reading cookies from Windows Chrome failed: ${(result.stderr || result.stdout || "no output").trim().slice(0, 400)}`,
180
+ );
181
+ }
182
+ const parsed = JSON.parse(result.stdout) as {
183
+ result?: { cookies?: CdpCookie[] };
184
+ error?: { message?: string };
185
+ };
186
+ if (!parsed.result?.cookies)
187
+ throw new Error(
188
+ `Windows Chrome returned no cookie list: ${parsed.error?.message ?? "unknown response"}`,
189
+ );
190
+ return cookiesForDomain(parsed.result.cookies, input.domain);
191
+ } finally {
192
+ rmSync(scriptLinux, { force: true });
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Close any Chrome still running on `profileWindows`, for when the person ends
198
+ * the sign-in from the terminal while the window is open. The profile must be
199
+ * closed before its cookies can be read. Closing the window first lets Chrome
200
+ * write recent cookies to disk; a forced stop is the fallback after 15 s.
201
+ */
202
+ export function stopWindowsChromeProfile(profileWindows: string): void {
203
+ const escaped = profileWindows.replace(/'/g, "''");
204
+ const select = `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -like '*${escaped}*' }`;
205
+ spawnSync(
206
+ "powershell.exe",
207
+ [
208
+ "-NoProfile",
209
+ "-Command",
210
+ [
211
+ `$ids = @(${select} | ForEach-Object { $_.ProcessId })`,
212
+ "foreach ($id in $ids) { $p = Get-Process -Id $id -ErrorAction SilentlyContinue; if ($p -and $p.MainWindowHandle -ne 0) { [void]$p.CloseMainWindow() } }",
213
+ "$deadline = (Get-Date).AddSeconds(15)",
214
+ `while ((Get-Date) -lt $deadline -and @(${select}).Count -gt 0) { Start-Sleep -Milliseconds 250 }`,
215
+ `${select} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
216
+ ].join("; "),
217
+ ],
218
+ { encoding: "utf8", timeout: 45_000 },
219
+ );
220
+ }
221
+
222
+ /** Windows form of a `/mnt/<drive>/...` path. */
223
+ export function wslPathToWindows(linuxPath: string): string {
224
+ const match = /^\/mnt\/([a-z])\/(.*)$/.exec(linuxPath);
225
+ if (!match) throw new Error(`Not a /mnt/<drive> path: ${linuxPath}`);
226
+ return `${(match[1] as string).toUpperCase()}:\\${(match[2] ?? "").replace(/\//g, "\\")}`;
227
+ }