harnery 0.5.0 → 0.7.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 (176) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +29 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +4 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +94 -33
  7. package/dist/commands/browse-ai.js +1 -1
  8. package/dist/commands/browse.d.ts.map +1 -1
  9. package/dist/commands/browse.js +41 -9
  10. package/dist/commands/cookies.js +1 -1
  11. package/dist/commands/decision.d.ts +4 -0
  12. package/dist/commands/decision.d.ts.map +1 -0
  13. package/dist/commands/decision.js +354 -0
  14. package/dist/commands/deinit.d.ts.map +1 -1
  15. package/dist/commands/deinit.js +4 -0
  16. package/dist/commands/devtools.d.ts +4 -0
  17. package/dist/commands/devtools.d.ts.map +1 -0
  18. package/dist/commands/devtools.js +239 -0
  19. package/dist/commands/docs.d.ts.map +1 -1
  20. package/dist/commands/docs.js +74 -2
  21. package/dist/commands/doctor.js +12 -4
  22. package/dist/commands/env.d.ts.map +1 -1
  23. package/dist/commands/env.js +3 -63
  24. package/dist/commands/fetch.js +1 -1
  25. package/dist/commands/init.d.ts +1 -0
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +54 -14
  28. package/dist/commands/scratch.js +1 -1
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +273 -62
  31. package/dist/commands/web-fetch.js +1 -1
  32. package/dist/core/agents/coord-client.d.ts.map +1 -1
  33. package/dist/core/agents/coord-client.js +32 -8
  34. package/dist/core/agents/events/consume.d.ts +25 -2
  35. package/dist/core/agents/events/consume.d.ts.map +1 -1
  36. package/dist/core/agents/events/consume.js +55 -7
  37. package/dist/core/agents/events/emit.d.ts +2 -1
  38. package/dist/core/agents/events/emit.d.ts.map +1 -1
  39. package/dist/core/agents/events/emit.js +6 -1
  40. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  41. package/dist/core/agents/rules/claim-conflict.js +16 -5
  42. package/dist/core/agents/state/scratch.d.ts +1 -1
  43. package/dist/core/agents/state/scratch.js +2 -2
  44. package/dist/core/config.d.ts +10 -0
  45. package/dist/core/config.d.ts.map +1 -1
  46. package/dist/core/config.js +13 -0
  47. package/dist/core/hooks/cli.js +3 -3
  48. package/dist/core/hooks/effects/index.d.ts +11 -7
  49. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  50. package/dist/core/hooks/effects/index.js +15 -17
  51. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  52. package/dist/core/hooks/events/emit.js +4 -0
  53. package/dist/core/hooks/events/rotate.d.ts +43 -0
  54. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  55. package/dist/core/hooks/events/rotate.js +142 -0
  56. package/dist/core/hooks/harness/events.d.ts +11 -1
  57. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  58. package/dist/core/hooks/harness/events.js +22 -3
  59. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  60. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  61. package/dist/core/hooks/harness/wiring.js +34 -5
  62. package/dist/core/scratch/index.d.ts.map +1 -0
  63. package/dist/{lib → core}/scratch/index.js +2 -2
  64. package/dist/lib/agent-browser/client.js +1 -1
  65. package/dist/lib/browser/client.d.ts +14 -0
  66. package/dist/lib/browser/client.d.ts.map +1 -1
  67. package/dist/lib/browser/client.js +20 -0
  68. package/dist/lib/browser/index.d.ts +1 -0
  69. package/dist/lib/browser/index.d.ts.map +1 -1
  70. package/dist/lib/browser/runts.d.ts +44 -0
  71. package/dist/lib/browser/runts.d.ts.map +1 -0
  72. package/dist/lib/browser/runts.js +193 -0
  73. package/dist/lib/completion/walk.js +1 -1
  74. package/dist/lib/cookies/client.d.ts +1 -1
  75. package/dist/lib/cookies/client.d.ts.map +1 -1
  76. package/dist/lib/cookies/client.js +1 -1
  77. package/dist/lib/decision/index.d.ts +212 -0
  78. package/dist/lib/decision/index.d.ts.map +1 -0
  79. package/dist/lib/decision/index.js +523 -0
  80. package/dist/lib/devtools.d.ts +178 -0
  81. package/dist/lib/devtools.d.ts.map +1 -0
  82. package/dist/lib/devtools.js +1328 -0
  83. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  84. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  85. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  86. package/dist/lib/docs-frontmatter.d.ts +33 -0
  87. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  88. package/dist/lib/docs-frontmatter.js +130 -0
  89. package/dist/lib/docs-index.d.ts +1 -0
  90. package/dist/lib/docs-index.d.ts.map +1 -1
  91. package/dist/lib/docs-index.js +4 -5
  92. package/dist/lib/docs-lint.d.ts +3 -0
  93. package/dist/lib/docs-lint.d.ts.map +1 -1
  94. package/dist/lib/docs-lint.js +67 -12
  95. package/dist/lib/docs-meta.d.ts +14 -0
  96. package/dist/lib/docs-meta.d.ts.map +1 -0
  97. package/dist/lib/docs-meta.js +34 -0
  98. package/dist/lib/docs-sweep.d.ts +12 -0
  99. package/dist/lib/docs-sweep.d.ts.map +1 -1
  100. package/dist/lib/docs-sweep.js +98 -103
  101. package/dist/lib/format.js +2 -2
  102. package/dist/lib/http/index.d.ts +1 -0
  103. package/dist/lib/http/index.d.ts.map +1 -1
  104. package/dist/lib/http/index.js +1 -0
  105. package/dist/lib/http/request.d.ts +77 -0
  106. package/dist/lib/http/request.d.ts.map +1 -0
  107. package/dist/lib/http/request.js +105 -0
  108. package/dist/lib/instructions/apply.d.ts +63 -0
  109. package/dist/lib/instructions/apply.d.ts.map +1 -0
  110. package/dist/lib/instructions/apply.js +255 -0
  111. package/dist/lib/instructions/splice.d.ts +73 -0
  112. package/dist/lib/instructions/splice.d.ts.map +1 -0
  113. package/dist/lib/instructions/splice.js +118 -0
  114. package/dist/lib/instructions/templates.d.ts +45 -0
  115. package/dist/lib/instructions/templates.d.ts.map +1 -0
  116. package/dist/lib/instructions/templates.js +258 -0
  117. package/dist/lib/tunnel/gate.d.ts +1 -0
  118. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  119. package/dist/lib/tunnel/gate.js +15 -10
  120. package/dist/lib/tunnel/state.d.ts +11 -1
  121. package/dist/lib/tunnel/state.d.ts.map +1 -1
  122. package/dist/lib/tunnel/state.js +8 -3
  123. package/package.json +9 -6
  124. package/src/commander.ts +35 -0
  125. package/src/commands/agents.ts +97 -29
  126. package/src/commands/browse-ai.ts +1 -1
  127. package/src/commands/browse.ts +63 -8
  128. package/src/commands/cookies.ts +1 -1
  129. package/src/commands/decision.ts +438 -0
  130. package/src/commands/deinit.ts +5 -0
  131. package/src/commands/devtools.ts +284 -0
  132. package/src/commands/docs.ts +86 -2
  133. package/src/commands/doctor.ts +13 -4
  134. package/src/commands/env.ts +11 -77
  135. package/src/commands/fetch.ts +1 -1
  136. package/src/commands/init.ts +66 -15
  137. package/src/commands/scratch.ts +1 -1
  138. package/src/commands/tunnel.ts +316 -65
  139. package/src/commands/web-fetch.ts +1 -1
  140. package/src/core/agents/coord-client.ts +34 -7
  141. package/src/core/agents/events/consume.ts +65 -7
  142. package/src/core/agents/events/emit.ts +7 -1
  143. package/src/core/agents/rules/claim-conflict.ts +17 -6
  144. package/src/core/agents/state/scratch.ts +2 -2
  145. package/src/core/config.ts +15 -1
  146. package/src/core/hooks/cli.ts +3 -3
  147. package/src/core/hooks/effects/index.ts +23 -16
  148. package/src/core/hooks/events/emit.ts +5 -0
  149. package/src/core/hooks/events/rotate.ts +151 -0
  150. package/src/core/hooks/harness/events.ts +30 -3
  151. package/src/core/hooks/harness/wiring.ts +46 -5
  152. package/src/{lib → core}/scratch/index.ts +2 -2
  153. package/src/lib/agent-browser/client.ts +1 -1
  154. package/src/lib/browser/client.ts +28 -0
  155. package/src/lib/browser/index.ts +4 -0
  156. package/src/lib/browser/runts.ts +218 -0
  157. package/src/lib/completion/walk.ts +1 -1
  158. package/src/lib/cookies/client.ts +2 -2
  159. package/src/lib/decision/index.ts +685 -0
  160. package/src/lib/devtools.ts +1653 -0
  161. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  162. package/src/lib/docs-frontmatter.ts +151 -0
  163. package/src/lib/docs-index.ts +4 -5
  164. package/src/lib/docs-lint.ts +61 -11
  165. package/src/lib/docs-meta.ts +44 -0
  166. package/src/lib/docs-sweep.ts +104 -102
  167. package/src/lib/format.ts +2 -2
  168. package/src/lib/http/index.ts +1 -0
  169. package/src/lib/http/request.ts +154 -0
  170. package/src/lib/instructions/apply.ts +318 -0
  171. package/src/lib/instructions/splice.ts +148 -0
  172. package/src/lib/instructions/templates.ts +295 -0
  173. package/src/lib/tunnel/gate.ts +15 -10
  174. package/src/lib/tunnel/state.ts +19 -4
  175. package/dist/lib/scratch/index.d.ts.map +0 -1
  176. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -28,6 +28,7 @@ import { homedir, tmpdir } from "node:os";
28
28
  import { basename, join, resolve } from "node:path";
29
29
  import type { Command } from "commander";
30
30
  import type { EmitContext } from "../commander.ts";
31
+ import { readStreamTailBounded } from "../core/agents/events/consume.ts";
31
32
  import {
32
33
  emitCanonical,
33
34
  type Heartbeat,
@@ -35,10 +36,18 @@ import {
35
36
  normalizeHarness,
36
37
  readHeartbeat,
37
38
  resolveOwner,
39
+ resolveOwnerBySessionEnv,
38
40
  resolveOwnerWithSource,
39
41
  } from "../core/agents/index.ts";
40
42
  import { resolveBinName } from "../core/config.ts";
43
+
44
+ /** Cap for CLI scans of the unbounded event ledger (`trace` / `health`). Well
45
+ * under V8's ~512MB max string length so a `readFileSync` of the whole file can
46
+ * never throw; covers ample recent history for a diagnostic scan. */
47
+ const STREAM_SCAN_CAP_BYTES = 128 * 1024 * 1024; // 128 MiB
48
+
41
49
  import { parsePsChainLine } from "../core/hooks/resolve/anchor.ts";
50
+ import { appendEntry, resolveOwnerByName } from "../core/scratch/index.ts";
42
51
  import {
43
52
  buildCouncilId,
44
53
  buildInviteMarkdown,
@@ -73,7 +82,6 @@ import {
73
82
  lookupById as lookupIdentityById,
74
83
  lookupByName as lookupIdentityByName,
75
84
  } from "../lib/identities/index.ts";
76
- import { appendEntry, resolveOwnerByName } from "../lib/scratch/index.ts";
77
85
 
78
86
  const FRESHNESS_SECS = 600; // 10-minute heartbeat-freshness window.
79
87
 
@@ -159,8 +167,7 @@ export function registerAgentsCommand(program: Command, emitParam: EmitContext):
159
167
  cmd
160
168
  .command("show <name>")
161
169
  .description(
162
- "Deep-dive on one peer agent: registry state (files held, last tool, task) " +
163
- "plus claude-sessions history (latest title, recent prompts, recent tools, tool-usage tallies). " +
170
+ "Deep-dive on one peer agent: registry state (files held, last tool, task, turn summary). " +
164
171
  "Disambiguates name → instance_id via prefix match.",
165
172
  )
166
173
  .option("--json", "JSON envelope output")
@@ -687,6 +694,7 @@ function runWhoami(opts: { json?: boolean }): void {
687
694
  process.exit(1);
688
695
  }
689
696
 
697
+ ensureCursorSession(root);
690
698
  const resolved = resolveOwnerWithSource();
691
699
  const myOwner = resolved.owner;
692
700
  if (!myOwner) {
@@ -1165,6 +1173,50 @@ function formatLocalShort(iso: string): string {
1165
1173
  }).format(d);
1166
1174
  }
1167
1175
 
1176
+ function cursorEnvSessionId(): string | null {
1177
+ const raw = process.env.CURSOR_SESSION_ID?.trim() || process.env.CURSOR_CONVERSATION_ID?.trim();
1178
+ if (!raw) return null;
1179
+ return raw.startsWith("bc-") && raw.length > 3 ? raw.slice(3) : raw;
1180
+ }
1181
+
1182
+ function shouldBootstrapCursorSession(): boolean {
1183
+ return process.env.CURSOR_AGENT === "1" && cursorEnvSessionId() !== null;
1184
+ }
1185
+
1186
+ function ensureCursorSession(root: string): void {
1187
+ if (!shouldBootstrapCursorSession()) return;
1188
+ if (resolveOwnerBySessionEnv(root)) return;
1189
+
1190
+ const sessionId = cursorEnvSessionId();
1191
+ if (!sessionId) return;
1192
+ const agentHook = resolve(root, "harnery", "bin", "agent-hook");
1193
+ if (!existsSync(agentHook)) return;
1194
+
1195
+ const payload = JSON.stringify({
1196
+ conversation_id: sessionId,
1197
+ session_id: sessionId,
1198
+ hook_event_name: "sessionStart",
1199
+ workspace_roots: [root],
1200
+ cwd: root,
1201
+ composer_mode: "agent",
1202
+ is_background_agent: false,
1203
+ });
1204
+
1205
+ spawnSync("bash", [agentHook, "session-start", "--harness", "cursor"], {
1206
+ input: payload,
1207
+ cwd: root,
1208
+ encoding: "utf8",
1209
+ timeout: 3000,
1210
+ env: {
1211
+ ...process.env,
1212
+ HARNERY_AGENT_COORD_PLATFORM: "cursor",
1213
+ HARNERY_COORD_ROOT_OVERRIDE: root,
1214
+ CURSOR_SESSION_ID: sessionId,
1215
+ CURSOR_CONVERSATION_ID: sessionId,
1216
+ },
1217
+ });
1218
+ }
1219
+
1168
1220
  function runReleaseClaim(path: string): void {
1169
1221
  const root = monorepoRoot();
1170
1222
  if (!root) {
@@ -1213,6 +1265,7 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1213
1265
  // Identity: prefer explicit --session-id (the ppid-walk-free escape hatch,
1214
1266
  // mirrors `status`), fall back to the ppid walk. Cursor shell tool calls
1215
1267
  // don't descend from a pid-map-registered anchor, so the walk can miss there.
1268
+ if (!opts?.sessionId) ensureCursorSession(root);
1216
1269
  const myOwner = opts?.sessionId ?? resolveOwner();
1217
1270
  if (!myOwner) {
1218
1271
  emit.error({
@@ -1265,6 +1318,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1265
1318
 
1266
1319
  // Identity resolution: prefer explicit --session-id (hook-friendly), fall
1267
1320
  // back to ppid walk for interactive shell usage.
1321
+ if (!opts.sessionId) ensureCursorSession(root);
1268
1322
  const myOwner = opts.sessionId ?? resolveOwner();
1269
1323
  if (!myOwner) {
1270
1324
  emit.error({
@@ -2098,8 +2152,12 @@ function readStreamStats(root: string): { bytes: number; lines: number; cursor_b
2098
2152
  let backlog = 0;
2099
2153
  let seenCursor = cursor === null; // no cursor → everything is "backlog"
2100
2154
  try {
2101
- const raw = readFileSync(streamPath, "utf8");
2102
- for (const line of raw.split("\n")) {
2155
+ // Bounded tail read: the ledger grows without bound and a whole-file
2156
+ // readFileSync throws past V8's ~512MB string limit. lines/backlog are then
2157
+ // scoped to the scanned window; `bytes` (full size, via statSync above) still
2158
+ // reflects the true stream size for the health rollup.
2159
+ const { text } = readStreamTailBounded(streamPath, STREAM_SCAN_CAP_BYTES);
2160
+ for (const line of text.split("\n")) {
2103
2161
  if (!line.trim()) continue;
2104
2162
  lines++;
2105
2163
  if (seenCursor) {
@@ -2225,24 +2283,34 @@ function runTrace(
2225
2283
  const limit = Math.max(1, Number.parseInt(opts.limit, 10) || 200);
2226
2284
  const candidateIds = targetId.includes("\x00") ? targetId.split("\x00") : [targetId];
2227
2285
 
2228
- // Scan the full stream once, bucket events by instance_id for the candidates.
2286
+ // Scan the stream tail once, bucket events by instance_id for the candidates.
2287
+ // Bounded read: the ledger grows without bound and a whole-file readFileSync
2288
+ // throws past V8's ~512MB string limit. A trace of an agent whose events all
2289
+ // predate the window is reported as truncated rather than crashing.
2229
2290
  const streamPath = resolve(root, ".harnery", "events.ndjson");
2230
2291
  const byId = new Map<string, CanonicalEvent[]>();
2231
- if (existsSync(streamPath)) {
2232
- for (const line of readFileSync(streamPath, "utf8").split("\n")) {
2233
- if (!line) continue;
2234
- try {
2235
- const ev = JSON.parse(line) as CanonicalEvent;
2236
- if (!ev.instance_id || !candidateIds.includes(ev.instance_id)) continue;
2237
- if (sinceMs && Date.parse(ev.ts) < sinceMs) continue;
2238
- const arr = byId.get(ev.instance_id) ?? [];
2239
- arr.push(ev);
2240
- byId.set(ev.instance_id, arr);
2241
- } catch {
2242
- /* skip */
2243
- }
2292
+ const { text: streamText, truncated: streamTruncated } = readStreamTailBounded(
2293
+ streamPath,
2294
+ STREAM_SCAN_CAP_BYTES,
2295
+ );
2296
+ for (const line of streamText.split("\n")) {
2297
+ if (!line) continue;
2298
+ try {
2299
+ const ev = JSON.parse(line) as CanonicalEvent;
2300
+ if (!ev.instance_id || !candidateIds.includes(ev.instance_id)) continue;
2301
+ if (sinceMs && Date.parse(ev.ts) < sinceMs) continue;
2302
+ const arr = byId.get(ev.instance_id) ?? [];
2303
+ arr.push(ev);
2304
+ byId.set(ev.instance_id, arr);
2305
+ } catch {
2306
+ /* skip */
2244
2307
  }
2245
2308
  }
2309
+ if (streamTruncated) {
2310
+ process.stderr.write(
2311
+ `note: event ledger exceeds ${Math.round(STREAM_SCAN_CAP_BYTES / 1024 / 1024)}MB; traced only the most recent window (older events omitted)\n`,
2312
+ );
2313
+ }
2246
2314
 
2247
2315
  // If the name mapped to multiple instances, trace the one with the latest event.
2248
2316
  let resolvedId = candidateIds[0]!;
@@ -2629,7 +2697,7 @@ function renderHealthBox(report: HealthReport): void {
2629
2697
  const localTime = formatLocalTime(new Date(report.generated_at));
2630
2698
  const title = `Coord Health (${localTime})`;
2631
2699
 
2632
- process.stdout.write(`${formatBox(title, rows)}\n`); // lint-ok-emission: chat-paste path; mirrors runStatus's direct write so the box surfaces in both TTY + bp-session-teed contexts
2700
+ process.stdout.write(`${formatBox(title, rows)}\n`); // lint-ok-emission: chat-paste path; mirrors runStatus's direct write so the box surfaces in both TTY + harn-session-teed contexts
2633
2701
 
2634
2702
  if (report.anomalies.length > 0) {
2635
2703
  process.stdout.write("\n"); // lint-ok-emission: same chat-paste path
@@ -2686,7 +2754,7 @@ function runHarnessProbe(
2686
2754
  // and `resolveOwner` here, so the probe reports exactly what the live hot
2687
2755
  // path resolves.
2688
2756
  const anchorTokens = new Set(["claude", "claude-code", "cursor", "codex"]);
2689
- const override = process.env.BP_AGENT_COORD_TEST_ANCHOR_PID;
2757
+ const override = process.env.HARNERY_AGENT_COORD_TEST_ANCHOR_PID;
2690
2758
  let anchorPid = override && Number(override) > 0 ? override : "";
2691
2759
  const chainParts: string[] = [];
2692
2760
  let walkPid = process.pid;
@@ -2795,12 +2863,12 @@ function runHarnessProbe(
2795
2863
  * dispatcher in an isolated sandbox.
2796
2864
  *
2797
2865
  * Sandbox isolation strategy:
2798
- * - mkdtempSync(tmpdir(), "bp-harness-probe-") creates a non-git tmp dir.
2866
+ * - mkdtempSync(tmpdir(), "harn-harness-probe-") creates a non-git tmp dir.
2799
2867
  * - The dispatcher's coord-root resolution falls back to
2800
- * `BP_COORD_ROOT_OVERRIDE` when git rev-parse fails. We set it to the sandbox.
2868
+ * `HARNERY_COORD_ROOT_OVERRIDE` when git rev-parse fails. We set it to the sandbox.
2801
2869
  * - We rewrite the payload's `cwd` field (Cursor cds to it) to the sandbox,
2802
2870
  * so real `.harnery/` never gets touched.
2803
- * - We set `BP_AGENT_COORD_OFF=0` explicitly so any user-side off-switch in
2871
+ * - We set `HARNERY_AGENT_COORD_OFF=0` explicitly so any user-side off-switch in
2804
2872
  * the environment doesn't mask adapter crashes.
2805
2873
  *
2806
2874
  * Sample shape: probe-meta wrapped (`_probe_meta.event` + `.payload`) OR bare
@@ -2880,7 +2948,7 @@ function replayHarnessSamples(
2880
2948
  };
2881
2949
  const harnessFlag = harness === "claude_code" ? "claude-code" : harness;
2882
2950
 
2883
- const sandbox = mkdtempSync(join(tmpdir(), "bp-harness-probe-"));
2951
+ const sandbox = mkdtempSync(join(tmpdir(), "harn-harness-probe-"));
2884
2952
  const results: SampleReplayResult[] = [];
2885
2953
 
2886
2954
  try {
@@ -2928,10 +2996,10 @@ function replayHarnessSamples(
2928
2996
  timeout: 10_000,
2929
2997
  env: {
2930
2998
  ...process.env,
2931
- BP_COORD_ROOT_OVERRIDE: sandbox,
2932
- BP_AGENT_COORD_HARNESS: harness,
2933
- BP_AGENT_COORD_PLATFORM: harness,
2934
- BP_AGENT_COORD_OFF: "0",
2999
+ HARNERY_COORD_ROOT_OVERRIDE: sandbox,
3000
+ HARNERY_AGENT_COORD_HARNESS: harness,
3001
+ HARNERY_AGENT_COORD_PLATFORM: harness,
3002
+ HARNERY_AGENT_COORD_OFF: "0",
2935
3003
  },
2936
3004
  });
2937
3005
 
@@ -99,7 +99,7 @@ function runBrowseAi(url: string, opts: BrowseAiOpts): void {
99
99
  const jar =
100
100
  opts.cookies === false
101
101
  ? null
102
- : new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "bp-browse-ai" });
102
+ : new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-browse-ai" });
103
103
  const ab = new AgentBrowser({
104
104
  jar,
105
105
  timeoutMs: Number.parseInt(opts.timeout, 10),
@@ -10,6 +10,7 @@ import {
10
10
  type DevOverlayResult,
11
11
  type Diagnostics,
12
12
  type OverflowResult,
13
+ type RuntsResult,
13
14
  type VisibilityResult,
14
15
  type WidthResult,
15
16
  } from "../lib/browser/index.ts";
@@ -77,16 +78,22 @@ interface BrowseOpts {
77
78
  checkVisibleThreshold?: string;
78
79
  checkVisibleFail?: boolean;
79
80
  checkVisibleSampleGrid?: string;
80
- checkVisibleNoAnnotate?: boolean;
81
+ // Commander maps `--no-check-visible-annotate` to `checkVisibleAnnotate: false`.
82
+ checkVisibleAnnotate?: boolean;
81
83
  // Width-fill check
82
84
  checkWidth?: string[];
83
85
  checkWidthThreshold?: string;
84
86
  checkWidthFail?: boolean;
85
- checkWidthNoAnnotate?: boolean;
87
+ checkWidthAnnotate?: boolean;
88
+ // `--check-runts [selector]`: true = whole body, string = scope selector.
89
+ checkRunts?: boolean | string;
90
+ checkRuntsMinChars?: string;
91
+ checkRuntsFail?: boolean;
92
+ checkRuntsAnnotate?: boolean;
86
93
  // Horizontal-overflow check
87
94
  checkOverflow?: boolean;
88
95
  checkOverflowFail?: boolean;
89
- checkOverflowNoAnnotate?: boolean;
96
+ checkOverflowAnnotate?: boolean;
90
97
  // Visual regression
91
98
  baseline?: string;
92
99
  diff?: string;
@@ -232,6 +239,24 @@ export function registerBrowseCommand(
232
239
  "--no-check-overflow-annotate",
233
240
  "Skip drawing overflow annotations on the screenshot (JSON still emitted).",
234
241
  )
242
+ .option(
243
+ "--check-runts [selector]",
244
+ "Scan text blocks for runts (a single word alone on a block's last visual line) " +
245
+ "by counting words on the last line via per-word Range rects — width thresholds " +
246
+ "miss narrow-column runts. Optional selector scopes the sweep (default: whole body). " +
247
+ "Atomic tokens (URLs, emails, phone numbers) are excluded. Reports hits in the JSON " +
248
+ "envelope under `runts` and annotates them on the screenshot.",
249
+ )
250
+ .option(
251
+ "--check-runts-min-chars <n>",
252
+ "Minimum block text length to scan (default 40; smaller labels can't meaningfully wrap).",
253
+ "40",
254
+ )
255
+ .option("--check-runts-fail", "Exit non-zero if any runt is detected.")
256
+ .option(
257
+ "--no-check-runts-annotate",
258
+ "Skip drawing runt boxes on the screenshot (JSON still emitted).",
259
+ )
235
260
  .option(
236
261
  "--baseline <name>",
237
262
  "Save the captured screenshot as a named baseline at " +
@@ -275,7 +300,7 @@ async function runBrowse(
275
300
  const jar =
276
301
  opts.cookies === false
277
302
  ? null
278
- : new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "bp-browse" });
303
+ : new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-browse" });
279
304
  const headed = opts.login || opts.headed;
280
305
  const viewport = parseViewport(opts.viewport ?? "desktop");
281
306
 
@@ -330,7 +355,7 @@ async function runBrowse(
330
355
  visibility = await browser.checkVisibility(opts.checkVisible, {
331
356
  sampleGrid: Number.parseInt(opts.checkVisibleSampleGrid ?? "3", 10),
332
357
  });
333
- if (opts.checkVisibleNoAnnotate !== true) {
358
+ if (opts.checkVisibleAnnotate !== false) {
334
359
  await browser.annotateVisibility(visibility);
335
360
  }
336
361
  }
@@ -343,9 +368,16 @@ async function runBrowse(
343
368
  if (opts.checkOverflow) {
344
369
  overflow = await browser.checkOverflow();
345
370
  }
371
+ let runts: RuntsResult | undefined;
372
+ if (opts.checkRunts) {
373
+ runts = await browser.checkRunts({
374
+ scope: typeof opts.checkRunts === "string" ? opts.checkRunts : null,
375
+ minChars: Number.parseInt(opts.checkRuntsMinChars ?? "40", 10),
376
+ });
377
+ }
346
378
  const widthThreshold = Number.parseFloat(opts.checkWidthThreshold ?? "0.9");
347
- const annotateWidth = widths && opts.checkWidthNoAnnotate !== true;
348
- const annotateOverflow = overflow && opts.checkOverflowNoAnnotate !== true;
379
+ const annotateWidth = widths && opts.checkWidthAnnotate !== false;
380
+ const annotateOverflow = overflow && opts.checkOverflowAnnotate !== false;
349
381
  if (annotateWidth || annotateOverflow) {
350
382
  await browser.annotateLayout({
351
383
  widths: annotateWidth ? widths! : [],
@@ -353,6 +385,10 @@ async function runBrowse(
353
385
  widthThreshold,
354
386
  });
355
387
  }
388
+ const annotateRunts = runts && runts.runts.length > 0 && opts.checkRuntsAnnotate !== false;
389
+ if (annotateRunts) {
390
+ await browser.annotateRunts(runts!);
391
+ }
356
392
 
357
393
  if (opts.login) {
358
394
  await new Promise<void>((res) => {
@@ -385,6 +421,7 @@ async function runBrowse(
385
421
  visibility,
386
422
  widths,
387
423
  overflow,
424
+ runts,
388
425
  devOverlay,
389
426
  batchResult,
390
427
  );
@@ -397,17 +434,21 @@ async function runBrowse(
397
434
  visibility,
398
435
  widths,
399
436
  overflow,
437
+ runts,
400
438
  devOverlay,
401
439
  batchResult,
402
440
  );
403
441
  }
404
442
 
405
- if (visibility && opts.checkVisibleNoAnnotate !== true) {
443
+ if (visibility && opts.checkVisibleAnnotate !== false) {
406
444
  await browser.clearVisibilityAnnotations();
407
445
  }
408
446
  if (annotateWidth || annotateOverflow) {
409
447
  await browser.clearLayoutAnnotations();
410
448
  }
449
+ if (annotateRunts) {
450
+ await browser.clearRuntsAnnotations();
451
+ }
411
452
 
412
453
  if (opts.checkVisibleFail && visibility) {
413
454
  const threshold = Number.parseFloat(opts.checkVisibleThreshold ?? "0.9");
@@ -446,6 +487,16 @@ async function runBrowse(
446
487
  }
447
488
  }
448
489
 
490
+ if (opts.checkRuntsFail && runts && runts.runts.length > 0) {
491
+ for (const hit of runts.runts) {
492
+ emit.log(
493
+ `check-runts FAIL ${hit.block}: last line is a lone "${hit.word}" ("…${hit.snippet.slice(-40)}")`,
494
+ "warn",
495
+ );
496
+ }
497
+ process.exitCode = 2;
498
+ }
499
+
449
500
  if (opts.checkOverflowFail && overflow?.hasHorizontalOverflow) {
450
501
  const culprit = overflow.widerThanViewport[0] ?? overflow.rightOverflow[0] ?? null;
451
502
  const detail = culprit
@@ -578,6 +629,7 @@ async function runPrintMode(
578
629
  visibility: VisibilityResult[] | undefined,
579
630
  widths: WidthResult[] | undefined,
580
631
  overflow: OverflowResult | undefined,
632
+ runts: RuntsResult | undefined,
581
633
  devOverlay: DevOverlayResult | undefined,
582
634
  batchResult: BatchResult | undefined,
583
635
  ): Promise<void> {
@@ -605,6 +657,7 @@ async function runPrintMode(
605
657
  if (visibility) result.visibility = visibility;
606
658
  if (widths) result.width = widths;
607
659
  if (overflow) result.overflow = overflow;
660
+ if (runts) result.runts = runts;
608
661
  if (devOverlay) result.devOverlay = devOverlay;
609
662
  if (batchResult && batchResult.clipboardReads.length > 0) {
610
663
  result.batchClipboardReads = batchResult.clipboardReads;
@@ -634,6 +687,7 @@ async function runTrioMode(
634
687
  visibility: VisibilityResult[] | undefined,
635
688
  widths: WidthResult[] | undefined,
636
689
  overflow: OverflowResult | undefined,
690
+ runts: RuntsResult | undefined,
637
691
  devOverlay: DevOverlayResult | undefined,
638
692
  batchResult: BatchResult | undefined,
639
693
  ): Promise<void> {
@@ -675,6 +729,7 @@ async function runTrioMode(
675
729
  if (visibility) envelope.visibility = visibility;
676
730
  if (widths) envelope.width = widths;
677
731
  if (overflow) envelope.overflow = overflow;
732
+ if (runts) envelope.runts = runts;
678
733
  if (devOverlay) envelope.devOverlay = devOverlay;
679
734
  if (batchResult && batchResult.clipboardReads.length > 0) {
680
735
  envelope.batchClipboardReads = batchResult.clipboardReads;
@@ -22,7 +22,7 @@ interface CommonOpts {
22
22
  function jarFrom(opts: CommonOpts): CookieJar {
23
23
  return new CookieJar({
24
24
  path: opts.store ?? DEFAULT_STORE,
25
- source: "bp-cookies",
25
+ source: "harn-cookies",
26
26
  });
27
27
  }
28
28