harnery 0.46.1 → 0.47.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 +24 -0
  2. package/bin/README.md +10 -0
  3. package/bin/agent-coord.mjs +3 -0
  4. package/bin/agent-hook.mjs +3 -0
  5. package/bin/harn.mjs +3 -0
  6. package/bin/run.mjs +43 -0
  7. package/dist/commander.d.ts +18 -6
  8. package/dist/commander.d.ts.map +1 -1
  9. package/dist/commander.js +74 -2
  10. package/dist/commands/agents.d.ts +25 -0
  11. package/dist/commands/agents.d.ts.map +1 -1
  12. package/dist/commands/agents.js +70 -27
  13. package/dist/commands/autoupdate.d.ts +12 -0
  14. package/dist/commands/autoupdate.d.ts.map +1 -0
  15. package/dist/commands/autoupdate.js +190 -0
  16. package/dist/commands/computer.d.ts +45 -0
  17. package/dist/commands/computer.d.ts.map +1 -0
  18. package/dist/commands/computer.js +509 -0
  19. package/dist/commands/docs.js +3 -1
  20. package/dist/commands/fetch.js +11 -1
  21. package/dist/commands/grep.d.ts +72 -0
  22. package/dist/commands/grep.d.ts.map +1 -1
  23. package/dist/commands/grep.js +200 -47
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +15 -9
  26. package/dist/commands/rm.js +2 -2
  27. package/dist/commands/web.d.ts.map +1 -1
  28. package/dist/commands/web.js +2 -0
  29. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  30. package/dist/core/agents/canonical-emit.js +2 -3
  31. package/dist/core/agents/coord-bin.d.ts +3 -0
  32. package/dist/core/agents/coord-bin.d.ts.map +1 -1
  33. package/dist/core/agents/coord-bin.js +6 -0
  34. package/dist/core/agents/coord-client.d.ts.map +1 -1
  35. package/dist/core/agents/coord-client.js +6 -2
  36. package/dist/core/agents/render/prompt-context.d.ts +55 -7
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +122 -48
  39. package/dist/core/agents/state/proc-start.d.ts +3 -1
  40. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  41. package/dist/core/agents/state/proc-start.js +26 -4
  42. package/dist/core/autoupdate/engine.d.ts +97 -0
  43. package/dist/core/autoupdate/engine.d.ts.map +1 -0
  44. package/dist/core/autoupdate/engine.js +548 -0
  45. package/dist/core/autoupdate/fingerprint.d.ts +16 -0
  46. package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
  47. package/dist/core/autoupdate/fingerprint.js +73 -0
  48. package/dist/core/autoupdate/git.d.ts +30 -0
  49. package/dist/core/autoupdate/git.d.ts.map +1 -0
  50. package/dist/core/autoupdate/git.js +82 -0
  51. package/dist/core/autoupdate/index.d.ts +6 -0
  52. package/dist/core/autoupdate/index.d.ts.map +1 -0
  53. package/dist/core/autoupdate/index.js +4 -0
  54. package/dist/core/autoupdate/state.d.ts +59 -0
  55. package/dist/core/autoupdate/state.d.ts.map +1 -0
  56. package/dist/core/autoupdate/state.js +214 -0
  57. package/dist/core/autoupdate/types.d.ts +113 -0
  58. package/dist/core/autoupdate/types.d.ts.map +1 -0
  59. package/dist/core/autoupdate/types.js +35 -0
  60. package/dist/core/config.d.ts +53 -1
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +114 -1
  63. package/dist/core/events/v3/bootstrap.js +1 -0
  64. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  65. package/dist/core/events/v3/runtime-build.js +1 -0
  66. package/dist/core/hooks/adapter/wiring.d.ts +12 -2
  67. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  68. package/dist/core/hooks/adapter/wiring.js +38 -8
  69. package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
  70. package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
  71. package/dist/core/hooks/autoupdate-cue.js +53 -0
  72. package/dist/core/hooks/cli.js +28 -3
  73. package/dist/core/hooks/effects/index.js +4 -4
  74. package/dist/core/hooks/resolve/coord-root.js +1 -0
  75. package/dist/core/semantic/contract.d.ts +3 -3
  76. package/dist/core/semantic/service.d.ts.map +1 -1
  77. package/dist/core/semantic/service.js +4 -2
  78. package/dist/core/storage/modes.d.ts +1 -1
  79. package/dist/core/storage/modes.d.ts.map +1 -1
  80. package/dist/core/storage/modes.js +5 -1
  81. package/dist/core/workflow/durable-record.d.ts +2 -0
  82. package/dist/core/workflow/durable-record.d.ts.map +1 -1
  83. package/dist/core/workflow/durable-record.js +13 -1
  84. package/dist/core/workflow/engine.js +4 -0
  85. package/dist/core/workflow/spawn-claude.d.ts +7 -0
  86. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  87. package/dist/core/workflow/spawn-claude.js +16 -4
  88. package/dist/core/workflow/specialists.d.ts.map +1 -1
  89. package/dist/core/workflow/specialists.js +1 -0
  90. package/dist/core/workflow/types.d.ts +10 -0
  91. package/dist/core/workflow/types.d.ts.map +1 -1
  92. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  93. package/dist/core/workflow/workspaces/leases.js +1 -9
  94. package/dist/lib/computer/cua.d.ts +50 -0
  95. package/dist/lib/computer/cua.d.ts.map +1 -0
  96. package/dist/lib/computer/cua.js +474 -0
  97. package/dist/lib/computer/host.d.ts +19 -0
  98. package/dist/lib/computer/host.d.ts.map +1 -0
  99. package/dist/lib/computer/host.js +72 -0
  100. package/dist/lib/computer/lease.d.ts +13 -0
  101. package/dist/lib/computer/lease.d.ts.map +1 -0
  102. package/dist/lib/computer/lease.js +269 -0
  103. package/dist/lib/computer/types.d.ts +163 -0
  104. package/dist/lib/computer/types.d.ts.map +1 -0
  105. package/dist/lib/computer/types.js +10 -0
  106. package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
  107. package/dist/lib/docs-metadata-v2.js +6 -5
  108. package/dist/lib/exec.d.ts.map +1 -1
  109. package/dist/lib/exec.js +1 -0
  110. package/dist/lib/guarded-remove.d.ts.map +1 -1
  111. package/dist/lib/guarded-remove.js +5 -4
  112. package/dist/lib/headless/index.d.ts +33 -0
  113. package/dist/lib/headless/index.d.ts.map +1 -1
  114. package/dist/lib/headless/index.js +38 -2
  115. package/dist/lib/http/client.d.ts +2 -9
  116. package/dist/lib/http/client.d.ts.map +1 -1
  117. package/dist/lib/http/client.js +30 -9
  118. package/dist/lib/instructions/apply.d.ts +9 -0
  119. package/dist/lib/instructions/apply.d.ts.map +1 -1
  120. package/dist/lib/instructions/apply.js +92 -14
  121. package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
  122. package/dist/lib/instructions/git-hooks.js +8 -4
  123. package/dist/lib/instructions/templates.d.ts +11 -0
  124. package/dist/lib/instructions/templates.d.ts.map +1 -1
  125. package/dist/lib/instructions/templates.js +81 -1
  126. package/package.json +5 -5
  127. package/schemas/config.schema.json +89 -1
  128. package/src/commander.ts +98 -8
  129. package/src/commands/agents.ts +100 -32
  130. package/src/commands/autoupdate.ts +250 -0
  131. package/src/commands/computer.ts +682 -0
  132. package/src/commands/docs.ts +3 -1
  133. package/src/commands/fetch.ts +11 -1
  134. package/src/commands/grep.ts +274 -59
  135. package/src/commands/init.ts +17 -7
  136. package/src/commands/rm.ts +2 -2
  137. package/src/commands/web.ts +2 -0
  138. package/src/core/agents/canonical-emit.ts +2 -3
  139. package/src/core/agents/coord-bin.ts +30 -0
  140. package/src/core/agents/coord-client.ts +6 -2
  141. package/src/core/agents/render/prompt-context.ts +152 -53
  142. package/src/core/agents/state/proc-start.ts +29 -5
  143. package/src/core/autoupdate/engine.ts +691 -0
  144. package/src/core/autoupdate/fingerprint.ts +89 -0
  145. package/src/core/autoupdate/git.ts +120 -0
  146. package/src/core/autoupdate/index.ts +29 -0
  147. package/src/core/autoupdate/state.ts +269 -0
  148. package/src/core/autoupdate/types.ts +135 -0
  149. package/src/core/config.ts +142 -1
  150. package/src/core/events/v3/bootstrap.ts +1 -0
  151. package/src/core/events/v3/runtime-build.ts +1 -0
  152. package/src/core/hooks/adapter/wiring.ts +51 -6
  153. package/src/core/hooks/autoupdate-cue.ts +72 -0
  154. package/src/core/hooks/cli.ts +27 -1
  155. package/src/core/hooks/effects/index.ts +4 -4
  156. package/src/core/hooks/resolve/coord-root.ts +1 -0
  157. package/src/core/semantic/service.ts +6 -2
  158. package/src/core/storage/modes.ts +7 -1
  159. package/src/core/workflow/durable-record.ts +15 -1
  160. package/src/core/workflow/engine.ts +3 -0
  161. package/src/core/workflow/spawn-claude.ts +16 -4
  162. package/src/core/workflow/specialists.ts +1 -0
  163. package/src/core/workflow/types.ts +10 -0
  164. package/src/core/workflow/workspaces/leases.ts +1 -9
  165. package/src/lib/computer/cua.ts +572 -0
  166. package/src/lib/computer/host.ts +87 -0
  167. package/src/lib/computer/lease.ts +299 -0
  168. package/src/lib/computer/types.ts +177 -0
  169. package/src/lib/docs-metadata-v2.ts +24 -5
  170. package/src/lib/exec.ts +1 -0
  171. package/src/lib/guarded-remove.ts +5 -3
  172. package/src/lib/headless/index.ts +66 -2
  173. package/src/lib/http/client.ts +33 -19
  174. package/src/lib/instructions/apply.ts +100 -12
  175. package/src/lib/instructions/git-hooks.ts +8 -4
  176. package/src/lib/instructions/templates.ts +95 -1
@@ -90,6 +90,17 @@ async function runFetch(
90
90
  headers[h.slice(0, idx).trim()] = h.slice(idx + 1).trim();
91
91
  }
92
92
 
93
+ // Caller headers only: the User-Agent above is ours, not the caller's.
94
+ if (context?.headerPolicy) {
95
+ const host = new URL(url).hostname;
96
+ for (const h of opts.header ?? []) {
97
+ const idx = h.indexOf(":");
98
+ const name = h.slice(0, idx).trim();
99
+ const verdict = context.headerPolicy(host, name, h.slice(idx + 1).trim());
100
+ if (!verdict.allow) throw new Error(verdict.message);
101
+ }
102
+ }
103
+
93
104
  const jar =
94
105
  opts.cookies !== false
95
106
  ? new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-fetch" })
@@ -111,7 +122,6 @@ async function runFetch(
111
122
  signal: ac.signal,
112
123
  extraHeaders: context?.extraHeaders,
113
124
  extraCookies: context?.extraCookies,
114
- clientRedirects: context?.clientRedirects?.() || undefined,
115
125
  responseType: "bytes",
116
126
  });
117
127
  } finally {
@@ -35,8 +35,29 @@ import { resolveSearchEngine, type SearchEngine } from "../lib/tools/ripgrep.ts"
35
35
  * envelope `{ pattern, mode, engine, and_patterns, without_patterns, repos,
36
36
  * total_matches, total_files, truncated, elapsed_ms }`. Matches are sorted
37
37
  * (file, then line) for stable output across runs and engines.
38
+ *
39
+ * Output and paging: a search shows every result row unless `--limit <n>`
40
+ * sets a page size, and clips each printed line to DEFAULT_MAX_COLUMNS
41
+ * characters (`--max-columns 0` turns clipping off). 500 columns keeps any
42
+ * hand-written source line and long prose lines while cutting minified or
43
+ * generated ones; on the v2 tune split a 300-column clip lost a claim that
44
+ * 500 kept, at about 8% more tokens than 300. There is
45
+ * no default row bound: on the context bench's v2 tune split, every default
46
+ * from 50 to 300 rows lost answer evidence that the unbounded search found
47
+ * (plan 2026-10-31_context-round-trips, Track D). When rows are left out, a count pass
48
+ * (the same search with `-c`, which prints one short row per file) supplies
49
+ * the totals, and the last line states how many matches and files were left
50
+ * out and the `--page <n>` that continues. Pages are cut from one global order
51
+ * (repo order, then file path, then line), so `--page 2` starts exactly where
52
+ * page 1 stopped, whichever engine ran. A content page is fetched by searching
53
+ * only the files that hold its rows.
38
54
  */
39
55
 
56
+ /** Characters kept per printed line unless --max-columns says otherwise. */
57
+ export const DEFAULT_MAX_COLUMNS = 500;
58
+ /** Explicit file arguments per engine call when fetching a content page. */
59
+ const PAGE_FILE_BATCH = 200;
60
+
40
61
  const DEFAULT_EXCLUDE_DIRS = [
41
62
  ".git",
42
63
  ".cache",
@@ -94,6 +115,11 @@ export interface GrepOpts {
94
115
  beforeContext?: string;
95
116
  maxCount?: string;
96
117
  limit?: string;
118
+ /** 1-based page of `limit`-sized pages. */
119
+ page?: string;
120
+ /** Lift the default row bound (no paging). */
121
+ /** Characters kept per line; "0" disables clipping. */
122
+ maxColumns?: string;
97
123
  include?: string[];
98
124
  exclude?: string[];
99
125
  and?: string[];
@@ -110,6 +136,8 @@ export interface Match {
110
136
  text: string;
111
137
  /** "match" = primary result row (counts toward totals + --limit); "context" = free -C/-A/-B row. */
112
138
  kind: "match" | "context";
139
+ /** Characters removed from `text` by --max-columns clipping (absent when whole). */
140
+ clipped?: number;
113
141
  }
114
142
 
115
143
  export function registerGrepCommand(
@@ -150,7 +178,12 @@ export function registerGrepCommand(
150
178
  "Print N lines before each match (overrides -C's before side)",
151
179
  )
152
180
  .option("--max-count <n>", "Stop after N matches per file")
153
- .option("--limit <n>", "Truncate output to N matches total")
181
+ .option("--limit <n>", "Show at most N result rows per page (default: every row)")
182
+ .option("--page <n>", "Show page N of --limit-sized pages (1-based; default 1; needs --limit)")
183
+ .option(
184
+ "--max-columns <n>",
185
+ `Clip each printed line to N characters (default ${DEFAULT_MAX_COLUMNS}; 0 = no clipping)`,
186
+ )
154
187
  .option(
155
188
  "--and <pattern>",
156
189
  "Only show files that ALSO contain this pattern (file-level, repeatable)",
@@ -201,15 +234,32 @@ export interface GrepResult {
201
234
  and_patterns: string[];
202
235
  without_patterns: string[];
203
236
  repos: { name: string; cwd: string; matches: Match[]; truncated: boolean }[];
237
+ /** Result rows shown on this page (context rows excluded). */
204
238
  total_matches: number;
239
+ /** Files with at least one shown row. */
205
240
  total_files: number;
241
+ /** More rows exist after this page. */
206
242
  truncated: boolean;
243
+ /** Page size; null without --limit. */
244
+ limit: number | null;
245
+ page: number;
246
+ /** Result rows / files the whole search found, across every page. */
247
+ available_matches: number;
248
+ available_files: number;
249
+ /** Rows / files the search found that this page does not show. */
250
+ omitted_matches: number;
251
+ omitted_files: number;
252
+ /** The --page value that continues, or null on the last page. */
253
+ next_page: number | null;
207
254
  elapsed_ms: number;
208
255
  }
209
256
 
210
257
  /** Validated, resolved options — built once before repo fan-out. */
211
258
  interface NormOpts {
212
- limit: number; // Infinity when unset
259
+ limit: number; // page size; Infinity without --limit
260
+ offset: number; // rows skipped before this page
261
+ page: number;
262
+ maxColumns: number; // Infinity when clipping is off
213
263
  maxCount: number | undefined;
214
264
  before: number;
215
265
  after: number;
@@ -257,6 +307,11 @@ function normalizeLangs(lang: string | string[] | undefined): string[] | undefin
257
307
  /** Validate numerics, resolve context sides, expand langs, enforce the compatibility matrix. */
258
308
  function normalizeOpts(opts: GrepOpts): NormOpts {
259
309
  const limit = opts.limit ? parseIntOpt("--limit", opts.limit, 1) : Number.POSITIVE_INFINITY;
310
+ const page = opts.page ? parseIntOpt("--page", opts.page, 1) : 1;
311
+ const maxColumnsRaw =
312
+ opts.maxColumns !== undefined
313
+ ? parseIntOpt("--max-columns", opts.maxColumns, 0)
314
+ : DEFAULT_MAX_COLUMNS;
260
315
  const maxCount = opts.maxCount ? parseIntOpt("--max-count", opts.maxCount, 1) : undefined;
261
316
  const c = opts.context !== undefined ? parseIntOpt("-C/--context", opts.context, 0) : 0;
262
317
  const before =
@@ -303,15 +358,20 @@ function normalizeOpts(opts: GrepOpts): NormOpts {
303
358
  [opts.filesOnly, "-l/--files-only"],
304
359
  [opts.count, "-c/--count"],
305
360
  [opts.limit, "--limit"],
361
+ [opts.page, "--page"],
306
362
  ];
307
363
  for (const [set, flag] of rejected) {
308
364
  if (set) throw new Error(`-q/--quiet does not combine with ${flag}`);
309
365
  }
310
366
  }
367
+ if (opts.page && !opts.limit) throw new Error("--page needs --limit (the page size)");
311
368
 
312
369
  return {
313
370
  // -q only needs existence: one accepted primary row settles the exit code.
314
371
  limit: opts.quiet ? 1 : limit,
372
+ offset: opts.quiet || !Number.isFinite(limit) ? 0 : (page - 1) * limit,
373
+ page,
374
+ maxColumns: maxColumnsRaw === 0 ? Number.POSITIVE_INFINITY : maxColumnsRaw,
315
375
  maxCount,
316
376
  before,
317
377
  after,
@@ -339,66 +399,148 @@ export async function runGrep(
339
399
  // ride the same --no-default-excludes gate as the built-in list.
340
400
  const hostExcludeDirs = opts.noDefaultExcludes ? [] : (context?.grepExcludeDirs ?? []);
341
401
 
342
- // All repos are searched concurrently; each collects at most limit+1
343
- // accepted rows (one row of lookahead, so "exactly N results" is
344
- // distinguishable from "more than N"), then the global budget is applied
345
- // in repo order below so `--limit` semantics stay deterministic.
346
- const acceptLimit = Number.isFinite(norm.limit) ? norm.limit + 1 : Number.POSITIVE_INFINITY;
347
- const perRepo = await Promise.all(
348
- repos.map((repo) => {
349
- // In --all-repos mode the parent scan prunes submodule dirs — each
350
- // submodule gets its own scoped scan, so descending from the parent
351
- // would double-scan and double-report every submodule match. This
352
- // pruning is correctness (one repo owns each match), so it applies
353
- // even when --no-default-excludes is set.
354
- const dedupeDirs = opts.allRepos && repo.name === "parent" ? (context?.submodules ?? []) : [];
355
- const extraDirs = [...hostExcludeDirs, ...dedupeDirs];
356
- return runGrepInRepo(
357
- pattern,
358
- paths,
359
- opts,
360
- norm,
361
- repo.cwd,
362
- repo.name,
363
- acceptLimit,
364
- engine,
365
- rgBin,
366
- extraDirs,
367
- );
368
- }),
369
- );
402
+ // In --all-repos mode the parent scan prunes submodule dirs — each
403
+ // submodule gets its own scoped scan, so descending from the parent would
404
+ // double-scan and double-report every submodule match. This pruning is
405
+ // correctness (one repo owns each match), so it applies even when
406
+ // --no-default-excludes is set.
407
+ const extraDirsFor = (repo: { name: string }): string[] => {
408
+ const dedupeDirs = opts.allRepos && repo.name === "parent" ? (context?.submodules ?? []) : [];
409
+ return [...hostExcludeDirs, ...dedupeDirs];
410
+ };
411
+ const scan = (
412
+ repo: { name: string; cwd: string },
413
+ scanPaths: string[],
414
+ scanOpts: GrepOpts,
415
+ scanNorm: NormOpts,
416
+ acceptLimit: number,
417
+ ): Promise<Match[]> =>
418
+ runGrepInRepo(
419
+ pattern,
420
+ scanPaths,
421
+ scanOpts,
422
+ scanNorm,
423
+ repo.cwd,
424
+ repo.name,
425
+ acceptLimit,
426
+ engine,
427
+ rgBin,
428
+ extraDirsFor(repo),
429
+ );
430
+ const byPosition = (a: Match, b: Match) =>
431
+ a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line;
432
+
433
+ // Every path yields, per repo, the selected rows (sorted) plus what the
434
+ // whole search found there, so the page window can be stated exactly.
435
+ let selected: Match[][];
436
+ let available: { matches: number; files: Set<string> }[];
437
+ let after: boolean[]; // rows exist in this repo after the page window
438
+
439
+ if (opts.quiet) {
440
+ // Existence only: one accepted row settles the exit code, so the engine is
441
+ // stopped at the first hit and no count pass runs.
442
+ selected = await Promise.all(repos.map((repo) => scan(repo, paths, opts, norm, 1)));
443
+ available = selected.map((rows) => ({
444
+ matches: rows.length,
445
+ files: new Set(rows.map((m) => m.file)),
446
+ }));
447
+ after = selected.map(() => false);
448
+ } else if (!Number.isFinite(norm.limit) || opts.files || opts.filesOnly || opts.count) {
449
+ // No page size, or a mode whose rows are files (one short row each): collect the
450
+ // complete result, then cut the page from its sorted order.
451
+ const all = await Promise.all(
452
+ repos.map((repo) => scan(repo, paths, opts, norm, Number.POSITIVE_INFINITY)),
453
+ );
454
+ for (const rows of all) rows.sort(byPosition);
455
+ available = all.map((rows) => ({
456
+ matches: rows.length,
457
+ files: new Set(rows.map((m) => m.file)),
458
+ }));
459
+ const windows = pageWindows(
460
+ all.map((rows) => rows.map(() => 1)),
461
+ norm.offset,
462
+ norm.limit,
463
+ );
464
+ selected = all.map((rows, i) => rows.slice(windows[i]?.start ?? 0, windows[i]?.end ?? 0));
465
+ after = all.map((rows, i) => (windows[i]?.end ?? 0) < rows.length);
466
+ } else {
467
+ // Content page. The count pass reports one row per file (path + matching
468
+ // line count) for the whole search; the page window is computed from those
469
+ // counts, and only the files holding the window's rows are searched again.
470
+ const countOpts: GrepOpts = { ...opts, count: true };
471
+ const counts = await Promise.all(
472
+ repos.map((repo) => scan(repo, paths, countOpts, norm, Number.POSITIVE_INFINITY)),
473
+ );
474
+ for (const rows of counts) rows.sort(byPosition);
475
+ available = counts.map((rows) => ({
476
+ matches: rows.reduce((sum, r) => sum + r.line, 0),
477
+ files: new Set(rows.map((r) => r.file)),
478
+ }));
479
+ // Expand per-file counts into a row index space without materializing rows.
480
+ const windows = pageWindows(
481
+ counts.map((rows) => rows.map((r) => r.line)),
482
+ norm.offset,
483
+ norm.limit,
484
+ );
485
+ // The selected files already passed --and/--without, so the page fetch
486
+ // skips the membership scans.
487
+ const fetchNorm: NormOpts = { ...norm, andPatterns: [], withoutPatterns: [] };
488
+ selected = await Promise.all(
489
+ repos.map(async (repo, i) => {
490
+ const w = windows[i];
491
+ const rows = counts[i] ?? [];
492
+ if (!w || w.start >= w.end) return [];
493
+ const picks = sliceFiles(rows, w.start, w.end);
494
+ const fetched: Match[] = [];
495
+ for (let b = 0; b < picks.length; b += PAGE_FILE_BATCH) {
496
+ const batch = picks.slice(b, b + PAGE_FILE_BATCH);
497
+ fetched.push(
498
+ ...(await scan(
499
+ repo,
500
+ batch.map((p) => p.file),
501
+ opts,
502
+ fetchNorm,
503
+ Number.POSITIVE_INFINITY,
504
+ )),
505
+ );
506
+ }
507
+ fetched.sort(byPosition);
508
+ const out: Match[] = [];
509
+ for (const pick of picks) {
510
+ const inFile = fetched.filter((m) => m.file === pick.file);
511
+ out.push(...inFile.slice(pick.skip, pick.skip + pick.take));
512
+ }
513
+ return out;
514
+ }),
515
+ );
516
+ after = counts.map((_, i) => (windows[i]?.end ?? 0) < (available[i]?.matches ?? 0));
517
+ }
370
518
 
371
519
  const allRepoResults: GrepResult["repos"] = [];
372
520
  let totalMatches = 0;
373
521
  const filesSeen = new Set<string>();
374
- let truncated = false;
375
- let budget = norm.limit;
522
+ let availableMatches = 0;
523
+ let availableFiles = 0;
376
524
 
377
525
  for (let i = 0; i < repos.length; i++) {
378
526
  const repo = repos[i];
379
527
  if (!repo) continue;
380
- const collected = perRepo[i] ?? [];
381
- collected.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
382
- const take = Number.isFinite(budget)
383
- ? Math.min(collected.length, Math.max(0, budget))
384
- : collected.length;
385
- let matches = collected.slice(0, take);
386
- // truncated only when an accepted primary row was actually omitted —
387
- // the lookahead row (or a later-repo surplus) is the proof.
388
- const repoTruncated = collected.length > take;
389
- if (repoTruncated) truncated = true;
390
- if (Number.isFinite(budget)) budget -= take;
528
+ let matches = selected[i] ?? [];
391
529
  totalMatches += matches.length;
530
+ availableMatches += available[i]?.matches ?? 0;
531
+ availableFiles += available[i]?.files.size ?? 0;
392
532
  for (const m of matches) filesSeen.add(`${repo.name}/${m.file}`);
393
- // Context is materialized only for the SELECTED rows, after budgeting,
394
- // so context rows never consume the limit and trailing context is never
395
- // cut off by an engine kill.
533
+ // Context is materialized only for the SELECTED rows, after paging, so
534
+ // context rows never consume the limit and trailing context is never cut
535
+ // off by an engine kill.
396
536
  if (!opts.files && !opts.filesOnly && !opts.count && (norm.before > 0 || norm.after > 0)) {
397
537
  matches = await materializeContext(matches, repo.cwd, norm.before, norm.after);
398
538
  }
399
- allRepoResults.push({ name: repo.name, cwd: repo.cwd, matches, truncated: repoTruncated });
539
+ if (Number.isFinite(norm.maxColumns)) matches = matches.map((m) => clip(m, norm.maxColumns));
540
+ allRepoResults.push({ name: repo.name, cwd: repo.cwd, matches, truncated: after[i] ?? false });
400
541
  }
401
542
 
543
+ const truncated = allRepoResults.some((r) => r.truncated);
402
544
  return {
403
545
  pattern,
404
546
  mode: opts.files ? "files" : opts.literal ? "literal" : "regex",
@@ -409,10 +551,66 @@ export async function runGrep(
409
551
  total_matches: totalMatches,
410
552
  total_files: filesSeen.size,
411
553
  truncated,
554
+ limit: Number.isFinite(norm.limit) && !opts.quiet ? norm.limit : null,
555
+ page: opts.quiet ? 1 : norm.page,
556
+ available_matches: availableMatches,
557
+ available_files: availableFiles,
558
+ omitted_matches: availableMatches - totalMatches,
559
+ omitted_files: availableFiles - filesSeen.size,
560
+ next_page: truncated && !opts.quiet ? norm.page + 1 : null,
412
561
  elapsed_ms: Date.now() - started,
413
562
  };
414
563
  }
415
564
 
565
+ /**
566
+ * Cut the page window [offset, offset + limit) out of one global row order:
567
+ * repos in order, each repo's rows in sorted order. `weights[i][j]` is how many
568
+ * rows item j of repo i stands for (1 for a row, a file's count in the count
569
+ * pass). Returns each repo's window as row indices local to that repo.
570
+ */
571
+ function pageWindows(
572
+ weights: number[][],
573
+ offset: number,
574
+ limit: number,
575
+ ): { start: number; end: number }[] {
576
+ const end = offset + limit;
577
+ let base = 0;
578
+ return weights.map((items) => {
579
+ const size = items.reduce((sum, w) => sum + w, 0);
580
+ const start = Math.min(size, Math.max(0, offset - base));
581
+ const stop = Math.min(size, Math.max(0, end - base));
582
+ base += size;
583
+ return { start, end: stop };
584
+ });
585
+ }
586
+
587
+ /** Which files (and which of their rows) a local row window covers. */
588
+ function sliceFiles(
589
+ counts: Match[],
590
+ start: number,
591
+ end: number,
592
+ ): { file: string; skip: number; take: number }[] {
593
+ const picks: { file: string; skip: number; take: number }[] = [];
594
+ let pos = 0;
595
+ for (const row of counts) {
596
+ const fileStart = pos;
597
+ const fileEnd = pos + row.line;
598
+ pos = fileEnd;
599
+ if (fileEnd <= start) continue;
600
+ if (fileStart >= end) break;
601
+ const skip = Math.max(0, start - fileStart);
602
+ const take = Math.min(fileEnd, end) - fileStart - skip;
603
+ if (take > 0) picks.push({ file: row.file, skip, take });
604
+ }
605
+ return picks;
606
+ }
607
+
608
+ /** Clip a row's text to `max` characters, recording how many were removed. */
609
+ function clip(m: Match, max: number): Match {
610
+ if (m.text.length <= max) return m;
611
+ return { ...m, text: m.text.slice(0, max), clipped: m.text.length - max };
612
+ }
613
+
416
614
  function resolveRepos(
417
615
  opts: GrepOpts,
418
616
  context: HarneryProgramContext | undefined,
@@ -967,7 +1165,8 @@ function normalizeFile(file: string): string {
967
1165
  return file.startsWith("./") ? file.slice(2) : file;
968
1166
  }
969
1167
 
970
- function renderResult(r: GrepResult, opts: GrepOpts): string {
1168
+ /** Exported for tests (not part of the package exports map). */
1169
+ export function renderResult(r: GrepResult, opts: GrepOpts): string {
971
1170
  const lines: string[] = [];
972
1171
  const showRepoHeader = r.repos.length > 1;
973
1172
  for (const repo of r.repos) {
@@ -988,25 +1187,41 @@ function renderResult(r: GrepResult, opts: GrepOpts): string {
988
1187
  lines.push(m.file);
989
1188
  } else if (opts.count) {
990
1189
  lines.push(`${m.file}:${m.line}`);
991
- } else if (m.kind === "context") {
992
- lines.push(`${m.file}-${m.line}-${m.text}`);
993
1190
  } else {
994
- lines.push(`${m.file}:${m.line}:${m.text}`);
1191
+ const sep = m.kind === "context" ? "-" : ":";
1192
+ const more = m.clipped ? ` …[+${m.clipped} chars]` : "";
1193
+ lines.push(`${m.file}${sep}${m.line}${sep}${m.text}${more}`);
995
1194
  }
996
1195
  prev = m;
997
1196
  }
998
- if (repo.truncated) {
999
- lines.push(" (truncated; raise --limit)");
1000
- }
1001
1197
  }
1002
- if (r.total_matches === 0) {
1198
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
1199
+ const matchWord = (n: number) => `${n} match${n === 1 ? "" : "es"}`;
1200
+ const fileRows = Boolean(opts.files || opts.filesOnly || opts.count);
1201
+ if (r.total_matches === 0 && r.available_matches === 0) {
1003
1202
  const modeTag = r.mode === "literal" ? " (literal)" : r.mode === "files" ? " (files)" : "";
1004
1203
  lines.push(`(no matches for /${r.pattern}/${modeTag})`);
1005
- } else if (r.repos.length > 1 || r.truncated) {
1204
+ } else if (r.omitted_matches > 0) {
1205
+ // Something was left out: state the window, what was left out, and how to
1206
+ // continue. Never printed when the page shows everything.
1207
+ const first = r.limit === null ? 1 : (r.page - 1) * r.limit + 1;
1208
+ const last = first + r.total_matches - 1;
1209
+ const shown =
1210
+ r.total_matches === 0
1211
+ ? `Page ${r.page} is past the end`
1212
+ : fileRows
1213
+ ? `Showing ${first}-${last} of ${plural(r.available_matches, "file")}`
1214
+ : `Showing matches ${first}-${last} of ${r.available_matches} (in ${plural(r.available_files, "file")})`;
1215
+ const left = fileRows
1216
+ ? `${plural(r.omitted_matches, "file")} left out`
1217
+ : `${matchWord(r.omitted_matches)} in ${plural(r.omitted_files, "other file")} left out`;
1218
+ const next = r.next_page !== null ? `. Next: --page ${r.next_page}` : "";
1219
+ lines.push("");
1220
+ lines.push(`${shown}; ${left}${next}. (${r.elapsed_ms}ms, ${r.engine})`);
1221
+ } else if (r.repos.length > 1) {
1006
1222
  lines.push("");
1007
- const summary = `${r.total_matches} match${r.total_matches === 1 ? "" : "es"} across ${r.total_files} file${r.total_files === 1 ? "" : "s"}`;
1008
- const tail = r.truncated ? " (truncated; raise --limit)" : "";
1009
- lines.push(`${summary}${tail} (${r.elapsed_ms}ms, ${r.engine})`);
1223
+ const summary = `${matchWord(r.total_matches)} across ${plural(r.total_files, "file")}`;
1224
+ lines.push(`${summary} (${r.elapsed_ms}ms, ${r.engine})`);
1010
1225
  }
1011
1226
  return lines.join("\n");
1012
1227
  }
@@ -31,6 +31,7 @@ import {
31
31
  codexWindowsBridgeConfig,
32
32
  DEFAULT_BIN_NAME,
33
33
  pinnedBinName,
34
+ portableHookCommands,
34
35
  stripJsonComments,
35
36
  } from "../core/config.ts";
36
37
  import { canonicalJsonV3 } from "../core/events/v3/canonical.ts";
@@ -61,6 +62,7 @@ import {
61
62
  isAgentHookCommand,
62
63
  makeEntry,
63
64
  orderHandlerKeys,
65
+ projectWiringOptions,
64
66
  type SettingsFile,
65
67
  summarizeAdapterWiring,
66
68
  type WiringOptions,
@@ -133,7 +135,8 @@ function checkAdapterHooks(
133
135
  bin: string,
134
136
  ): { drift: boolean; error: boolean; issues: string[] } {
135
137
  const spec = ADAPTER_SPECS[adapter];
136
- const agentHook = agentHookPathForProject(projectRoot, HARNERY_ROOT);
138
+ const options = projectWiringOptions(projectRoot);
139
+ const agentHook = agentHookPathForProject(projectRoot, HARNERY_ROOT, options);
137
140
  if (spec.installMode === "opencode-plugin") {
138
141
  // No hooks map to diff: the plugin directory is the wiring.
139
142
  const plugin = checkOpenCodePlugin(projectRoot, { binName: bin, agentHook });
@@ -159,7 +162,7 @@ function checkAdapterHooks(
159
162
  const hookDiff = diffWiring(settings, spec, {
160
163
  agentHookPath: agentHook,
161
164
  adapter,
162
- windowsBridgeEntryPoint: codexWindowsBridgeConfig(projectRoot).entryPoint,
165
+ ...options,
163
166
  });
164
167
  drift =
165
168
  hookDiff.missing.length > 0 ||
@@ -444,7 +447,8 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
444
447
  for (const adapter of adapters) {
445
448
  const spec = ADAPTER_SPECS[adapter];
446
449
  const settingsPath = resolve(projectRoot, spec.settingsFile);
447
- const agentHook = agentHookPathForProject(projectRoot, HARNERY_ROOT);
450
+ const portableCommands = portableHookCommands(projectRoot);
451
+ const agentHook = agentHookPathForProject(projectRoot, HARNERY_ROOT, { portableCommands });
448
452
 
449
453
  if (spec.installMode === "opencode-plugin") {
450
454
  // OpenCode has no hooks map. Install the plugin directory instead; the
@@ -475,14 +479,16 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
475
479
  }
476
480
  const bridge = codexWindowsBridgeConfig(projectRoot);
477
481
  if (bridge.error && spec.windowsBridgeHooks) {
478
- actions.push(`⚠ ${bridge.error}; writing ${adapter} hooks without commandWindows`);
482
+ actions.push(
483
+ `⚠ ${bridge.error}; writing ${adapter} hooks without the Windows bridge command`,
484
+ );
479
485
  }
480
486
  const { wired, already, removed, upgraded } = wireHooks(
481
487
  settings,
482
488
  spec,
483
489
  agentHook,
484
490
  adapter,
485
- { windowsBridgeEntryPoint: bridge.entryPoint },
491
+ { windowsBridgeEntryPoint: bridge.entryPoint, portableCommands },
486
492
  );
487
493
 
488
494
  if (wired === 0 && removed === 0 && upgraded === 0) {
@@ -544,6 +550,7 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
544
550
 
545
551
  function gitBuild(root: string): string {
546
552
  const result = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
553
+ windowsHide: true,
547
554
  encoding: "utf8",
548
555
  timeout: 5000,
549
556
  });
@@ -624,7 +631,7 @@ export function wireHooks(
624
631
  }
625
632
  }
626
633
  for (const { settingsKey, subcommand } of spec.events) {
627
- const command = hookCommand(spec, agentHookPath, subcommand, adapter);
634
+ const command = hookCommand(spec, agentHookPath, subcommand, adapter, options);
628
635
  const timeout = hookTimeoutFor(spec, subcommand);
629
636
  const commandWindows = expectedCommandWindows(
630
637
  spec,
@@ -974,7 +981,10 @@ function renderInstructionsOnly(
974
981
  }
975
982
 
976
983
  function gitTopLevel(): string | null {
977
- const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
984
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
985
+ encoding: "utf8",
986
+ windowsHide: true,
987
+ });
978
988
  const out = r.status === 0 ? r.stdout.trim() : "";
979
989
  return out || null;
980
990
  }
@@ -23,7 +23,7 @@ export function registerRmCommand(
23
23
  .description("Preview guarded removal of untracked files; --yes applies permanent deletion")
24
24
  .requiredOption(
25
25
  "--root <directory>",
26
- "Allowed directory; each target must be strictly inside it",
26
+ "Allowed directory; relative targets resolve under it, and each target must be strictly inside it",
27
27
  )
28
28
  .option("-r, --recursive", "Permit directory removal after inspecting its complete contents")
29
29
  .option(
@@ -37,7 +37,7 @@ export function registerRmCommand(
37
37
  )
38
38
  .option(
39
39
  "--allow-protected <path>",
40
- "Remove this one protected path (Git metadata, credentials, Harnery state, keys) inside a target; repeat per path",
40
+ "Remove this one protected path (Git metadata, credentials, Harnery state, keys) inside a target; relative paths resolve under --root; repeat per path",
41
41
  (value: string, previous: string[] = []) => [...previous, value],
42
42
  )
43
43
  .option("--yes", "Permanently remove validated targets")
@@ -267,6 +267,7 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
267
267
  if (heapMb > 0) emit.log(`heap ceiling · ${heapMb} MB`, "info");
268
268
 
269
269
  const child = spawn(webRunner(), ["run", mode], {
270
+ windowsHide: true,
270
271
  cwd: root,
271
272
  env: {
272
273
  ...process.env,
@@ -360,6 +361,7 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
360
361
  if (heapMb > 0) emit.log(`heap ceiling · ${heapMb} MB`, "info");
361
362
 
362
363
  const child = spawn(webRunner(), ["run", "start"], {
364
+ windowsHide: true,
363
365
  cwd: root,
364
366
  env: {
365
367
  ...process.env,
@@ -10,10 +10,9 @@
10
10
  * seconds and record the observation before the parent command exits.
11
11
  */
12
12
 
13
- import { spawnSync } from "node:child_process";
14
13
  import { type EventAdapterIdV3, eventAdapterIdV3FromPlatform } from "../events/v3/adapter-id.ts";
15
14
  import type { LiveCoordinationObservationV3 } from "../events/v3/live-observation.ts";
16
- import { coordBinPath } from "./coord-bin.ts";
15
+ import { coordBinPath, spawnCoordBinSync } from "./coord-bin.ts";
17
16
  import { resolveCoordRoot } from "./coord-client.ts";
18
17
  import type { Heartbeat } from "./state/heartbeat-reader.ts";
19
18
 
@@ -64,7 +63,7 @@ export function emitEventV3(input: EventV3EmitInput): boolean {
64
63
  ];
65
64
  // Pin cwd + HARNERY_COORD_ROOT_OVERRIDE so the child writes to the SAME
66
65
  // stream we resolved, regardless of where the caller's shell is cd'd.
67
- const result = spawnSync(binary, args, {
66
+ const result = spawnCoordBinSync(binary, args, {
68
67
  encoding: "utf8",
69
68
  stdio: ["pipe", "ignore", "pipe"],
70
69
  input: JSON.stringify(input.observation),
@@ -21,6 +21,12 @@
21
21
  * still resolves.
22
22
  */
23
23
 
24
+ import {
25
+ type SpawnSyncOptions,
26
+ type SpawnSyncOptionsWithStringEncoding,
27
+ type SpawnSyncReturns,
28
+ spawnSync,
29
+ } from "node:child_process";
24
30
  import { existsSync } from "node:fs";
25
31
  import { dirname, join, resolve } from "node:path";
26
32
  import { fileURLToPath } from "node:url";
@@ -28,6 +34,30 @@ import { coordEnv } from "../../lib/env.ts";
28
34
 
29
35
  export type CoordBinName = "agent-coord" | "agent-hook" | "harn";
30
36
 
37
+ export function spawnCoordBinSync(
38
+ binary: string,
39
+ args: readonly string[],
40
+ options: SpawnSyncOptionsWithStringEncoding,
41
+ ): SpawnSyncReturns<string>;
42
+ export function spawnCoordBinSync(
43
+ binary: string,
44
+ args: readonly string[],
45
+ options?: SpawnSyncOptions,
46
+ ): SpawnSyncReturns<string | Buffer>;
47
+ /** Execute the native launcher on Windows, preserving stdin, argv and blocking exit codes. */
48
+ export function spawnCoordBinSync(
49
+ binary: string,
50
+ args: readonly string[],
51
+ options: SpawnSyncOptions = {},
52
+ ): SpawnSyncReturns<string | Buffer> {
53
+ const native = process.platform === "win32";
54
+ return spawnSync(
55
+ native ? process.execPath : binary,
56
+ native ? [`${binary}.mjs`, ...args] : [...args],
57
+ { windowsHide: true, ...options },
58
+ );
59
+ }
60
+
31
61
  /**
32
62
  * Absolute path to one of harnery's bundled binaries, or null when no candidate
33
63
  * exists on disk. Callers must handle null — a missing helper is a real state