@catheadowl/ukp 0.2.4 → 0.2.5

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.
package/README.md CHANGED
@@ -61,7 +61,9 @@ predictable scope, and a single command surface. Use UKP when:
61
61
  Requirements). `read` and `nav` are derived defaults of every local
62
62
  Service.
63
63
  - Run base lexical search with `rg` across endpoint files — no search
64
- provider needed, on by default, shaped into `read`-ready references.
64
+ provider needed, on by default, shaped into `read`-ready references —
65
+ or enumerate them with `rg --files` (a tree walk: empty and binary files
66
+ included, hidden files behind `-- --hidden`).
65
67
  - Search indexed content, QMD-backed: one endpoint, the workspace default
66
68
  scope, or the whole local Registry. `--recursive` additionally searches
67
69
  the endpoints a Service declares in its manifest `dependencies` —
@@ -143,7 +145,7 @@ Endpoint commands:
143
145
  | `ukp search` | Indexed search through the endpoint's provider (QMD today); `--recursive` expands the manifest's declared `dependencies`. Results hand off via docid session keys and verified `ukp://` references. |
144
146
  | `ukp read` | Reads an endpoint-scoped resource — exact path, `ukp://` URI, or a `docid` handed off by search results. |
145
147
  | `ukp nav` | Markdown outline of an endpoint (`--depth`, respects `.gitignore`); on by default, configurable via `[capabilities.nav]`. |
146
- | `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning. |
148
+ | `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; `--files` enumerates them instead of searching. Results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning. |
147
149
  | `ukp propose` | Submits an idempotent change proposal (suggestion box — the owner decides what happens next). Created/unchanged/updated; the revision bumps only on `updated`. |
148
150
 
149
151
  Registry commands:
@@ -171,6 +173,7 @@ Help commands:
171
173
  | `ukp guide service` | Provider-agnostic Service setup path. |
172
174
  | `ukp guide service qmd` | Provider-owned setup for the QMD provider. |
173
175
  | `ukp guide client` | How a workspace uses registered Services by default. |
176
+ | `ukp guide rg` | The rg quickstart: modes, glob semantics, visibility tiers. |
174
177
  | `ukp guide remote` | How to serve and consume endpoints across machines. |
175
178
  | `ukp guide propose` | The propose quickstart. |
176
179
 
@@ -65,6 +65,13 @@ Notes:
65
65
  wake — the client chooses the door port per invocation, which a fixed
66
66
  forced command cannot express.
67
67
 
68
+ Windows note: some antivirus setups (Windows Defender behavioral inspection
69
+ included) delay a process spawned directly by the runtime by several seconds
70
+ per call — 5-10s per ssh/rg spawn was measured before ukp routed its
71
+ Windows tool spawns through a one-level relay. ukp does this automatically;
72
+ if you also add your own exclusion for your package manager's runtimes the
73
+ residual per-call cost is just the real ssh handshake plus the door spawn.
74
+
68
75
  Windows hosts work out of the box: when the host's OpenSSH default shell
69
76
  (cmd.exe) rejects the POSIX wake form, the client detects the signature and
70
77
  resends the pinned cmd.exe form automatically — nothing to configure beyond
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catheadowl/ukp",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.14"
@@ -19,11 +19,12 @@ import {
19
19
  type RemoteUrlParts,
20
20
  } from "../registry.ts";
21
21
  import { spkiPinOf } from "./tls-identity.ts";
22
+ import { toolRelayScript } from "../spawn-relay.ts";
22
23
  import type { SearchEndpointOutcome } from "./search.ts";
23
24
  import type { NavEnvelope } from "./nav.ts";
24
25
  import type { ProposeResult, ProposeStatus } from "./propose.ts";
25
26
  import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
26
- import type { RgEndpointOutcome, RgMatch, RgCountEntry } from "./rg.ts";
27
+ import type { RgEndpointOutcome, RgMatch, RgCountEntry, RgFileEntry } from "./rg.ts";
27
28
 
28
29
  /** Remote transport for the client side (ukp-remote wire v1, ADR-REM-002/003;
29
30
  * ukp_remote W2). All calls fetch fresh (no cross-invocation cache), carry the
@@ -381,6 +382,71 @@ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean
381
382
  return /['"]cmd['"]|cmd: (?:command )?not found/i.test(diagnostics);
382
383
  }
383
384
 
385
+ /** Wake failure classes from ssh's own diagnostics (ADR-REM-008 / D-093):
386
+ * OpenSSH's stderr wording is locale-stable (unlike cmd.exe's), so stable
387
+ * substrings classify the deterministic failures — verdicts a re-run against
388
+ * the same host reproduces, which is why none of them belongs in the retry
389
+ * ladder. One taxonomy, three matchers: these connect/auth signatures here,
390
+ * missing-ukp (127/9009 dictionary) and shell-mismatch keep their existing
391
+ * branches. Pure function by design — the signature table is unit-testable
392
+ * without spawning ssh. */
393
+ export type WakeFailureClass = "unreachable" | "refused" | "auth" | "hostkey";
394
+
395
+ export function classifyWakeFailure(diagnostics: string): WakeFailureClass | undefined {
396
+ if (/Connection timed out|No route to host|Could not resolve hostname/i.test(diagnostics)) return "unreachable";
397
+ if (/Connection refused/i.test(diagnostics)) return "refused";
398
+ if (/Host key verification failed/i.test(diagnostics)) return "hostkey";
399
+ // Auth failures always carry the method-list suffix ("Permission denied
400
+ // (publickey,password).") — ssh's own format. The bare phrase also appears
401
+ // in REMOTE-COMMAND exec errors (a found-but-not-executable ukp on a
402
+ // noexec mount), which reach these diagnostics under -tt and must keep
403
+ // falling through to the generic wording, not get the key-auth remedy
404
+ // (review P2).
405
+ if (/Permission denied \(/i.test(diagnostics)) return "auth";
406
+ return undefined;
407
+ }
408
+
409
+ /** A probe-stage timeout verdict is not trustworthy on its own (ADR-REM-008
410
+ * §3, implementation-time refinement): the probe's 5s race window is shorter
411
+ * than the full ConnectTimeout budget a slow-but-alive host may legitimately
412
+ * need — its wake would succeed in the ladder's window — so black-hole
413
+ * signatures defer to the ladder, which runs the full budget before the same
414
+ * classifier issues the verdict. Instant verdicts (refused, auth, hostkey,
415
+ * resolve and no-route failures) settle within one roundtrip and are trusted
416
+ * wherever they appear; a probe-local short ConnectTimeout would have
417
+ * misjudged every host slower than it. */
418
+ function wakeFailureDefersToLadder(failureClass: WakeFailureClass, diagnostics: string): boolean {
419
+ return failureClass === "unreachable"
420
+ && /Connection timed out/i.test(diagnostics)
421
+ && !/No route to host|Could not resolve hostname/i.test(diagnostics);
422
+ }
423
+
424
+ /** Classified wake-failure text (ADR-REM-008 §4): names the ssh TARGET (an
425
+ * origin-level fact under fan-out — never an endpoint label), quotes the ssh
426
+ * diagnostics as evidence, and gives the class its one remedy. Printable
427
+ * ASCII only — the frozen vocabulary rule for runtime strings. */
428
+ export function wakeFailureMessage(failureClass: WakeFailureClass, target: string, diagnostics: string): string {
429
+ const evidence = diagnostics.length > 0 ? `: ${diagnostics}` : "";
430
+ switch (failureClass) {
431
+ case "unreachable":
432
+ return `waking the ukp door on '${target}' failed - the host is unreachable${evidence}. The machine appears off, firewalled, or its address no longer resolves; verify it is powered on and reachable`;
433
+ case "refused":
434
+ return `waking the ukp door on '${target}' failed - the host is reachable but nothing is listening for ssh${evidence}. Start the ssh server on the host (on Windows: the optional OpenSSH Server feature) or fix the port`;
435
+ case "auth":
436
+ return `waking the ukp door on '${target}' failed - ssh rejected the credentials (BatchMode, no password prompt)${evidence}. Fix key auth: add the public key to the host's authorized_keys, or check the ssh config's IdentityFile`;
437
+ case "hostkey":
438
+ return `waking the ukp door on '${target}' failed - the host key was not accepted${evidence}. If the host was reinstalled this is expected: re-verify it (ssh-keygen -R), then retry`;
439
+ }
440
+ }
441
+
442
+ /** Not-ready wording (ADR-REM-008): the old text blamed the host alias and
443
+ * key auth — both, by the time this message is reached, have already
444
+ * succeeded (their failures classify earlier); what failed is the door
445
+ * behind a working connection. */
446
+ export function wakeNotReadyMessage(target: string, timeoutMs: number): string {
447
+ return `the ssh connection to '${target}' worked but the ukp door did not become ready within ${Math.round(timeoutMs / 1000)}s. Host prerequisite (W9): ukp on the remote PATH; a slow first start also lands here - retry once before investigating`;
448
+ }
449
+
384
450
  /** Cross-invocation shell-family cache (ukp_list W1 / L-1, ADR 0026's
385
451
  * implementation-time note): a host's shell family is a static fact, but
386
452
  * learning it costs a full ssh roundtrip per origin per invocation — on
@@ -426,6 +492,60 @@ function persistShellFamily(cachePath: string | undefined, target: string, form:
426
492
  }
427
493
  }
428
494
 
495
+ /** Structural subset of Bun's Subprocess that the ssh paths consume (a Pick,
496
+ * not a hand-written interface — the ADR 0021 guard scans for CLI-shaped
497
+ * member declarations). Direct spawns return the Subprocess itself; the win32
498
+ * relay below returns a delegating wrapper because its kill semantics differ
499
+ * (tree-kill). */
500
+ type HostSshProcess = Pick<ReturnType<typeof Bun.spawn>, "pid" | "exitCode" | "stdout" | "stderr" | "kill">;
501
+
502
+ /** Win32 Defender tax (2026-09-22 finding, ukp_remote TODO
503
+ * 20260922-win32-ssh-spawn-tax): the DIRECT bun-to-ssh.exe child edge is held
504
+ * ~10s by behavioral inspection — measured across stdio modes, windowsHide,
505
+ * and absolute paths, while bash-to-ssh is 0.16s and ANY intermediary parent
506
+ * (cmd, node, powershell) escapes to 0.5-0.8s. Every ssh spawn in this file
507
+ * pays it once, so on win32 real-ssh spawns go through a powershell.exe
508
+ * relay. -EncodedCommand carries the script as one base64 token (no quoting
509
+ * stress from Bun), and the script splats a PS array so the pinned wake
510
+ * command's load-bearing quotes reach ssh byte-identical (verified E2E: door
511
+ * READY in ~1.4s). Test injection (explicit sshCommand) bypasses the relay —
512
+ * the fakes must not be wrapped. Killing the relay orphans the ssh child
513
+ * (tunnel + door outlive the killed handle — verified), so the wrapped kill
514
+ * reaps the tree via taskkill /T /F. */
515
+ function spawnHostSsh(options: { sshCommand?: readonly string[] }, argv: readonly string[]): HostSshProcess {
516
+ if (process.platform !== "win32" || options.sshCommand !== undefined) {
517
+ return Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
518
+ }
519
+ const script = toolRelayScript(argv);
520
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
521
+ const proc = Bun.spawn(["powershell", "-NoProfile", "-EncodedCommand", encoded], {
522
+ stdout: "pipe",
523
+ stderr: "pipe",
524
+ stdin: "ignore",
525
+ });
526
+ const { pid, stdout, stderr } = proc;
527
+ return {
528
+ pid,
529
+ stdout,
530
+ stderr,
531
+ get exitCode() {
532
+ return proc.exitCode;
533
+ },
534
+ kill() {
535
+ // taskkill also carries the Defender tax when spawned directly from
536
+ // bun (measured 10.1s direct vs 105ms through cmd — same finding as
537
+ // the ssh/rg edges); the PID is digits-only, so the cmd payload needs
538
+ // no quoting. /T /F takes the relay AND its ssh child down together
539
+ // (killing the relay alone orphans the tunnel).
540
+ Bun.spawnSync(["cmd", "/d", "/c", `taskkill /PID ${pid} /T /F`], {
541
+ stdout: "ignore",
542
+ stderr: "ignore",
543
+ stdin: "ignore",
544
+ });
545
+ },
546
+ };
547
+ }
548
+
429
549
  /** One cheap, language-independent roundtrip to learn the host shell family
430
550
  * BEFORE the first wake attempt (2026-09-20, liku 9.5 finding): cmd.exe
431
551
  * expands `%OS%` to `Windows_NT`; a POSIX shell echoes the literal token.
@@ -436,21 +556,32 @@ function persistShellFamily(cachePath: string | undefined, target: string, form:
436
556
  * build LOSES a quoted remote command under a pty (an interactive cmd
437
557
  * swallows it and waits on stdin forever — the no-pty path executes it
438
558
  * correctly). Defaults to POSIX (the common server shape) when the probe
439
- * errors or times out; the ladder remains as the backstop. */
559
+ * errors or times out; the ladder remains as the backstop.
560
+ *
561
+ * The probe doubles as the first failure classifier (ADR-REM-008 §3): its
562
+ * merged output — the `%OS%` answer on stdout, ssh's own diagnostics on
563
+ * stderr — feeds `classifyWakeFailure`, so instant verdicts (refused / auth
564
+ * / hostkey / resolve failures) classify here and the ladder is never
565
+ * entered for them. */
440
566
  async function probeHostShellFamily(
441
567
  options: { sshCommand?: readonly string[] },
442
568
  target: string,
443
- ): Promise<WakeShellForm> {
444
- const proc = Bun.spawn(
569
+ ): Promise<{ form: WakeShellForm; diagnostics: string }> {
570
+ const proc = spawnHostSsh(
571
+ options,
445
572
  [...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", ...connectTimeoutOptions(), target, "echo %OS%"],
446
- { stdout: "pipe", stderr: "pipe", stdin: "ignore" },
447
573
  );
448
- const output = await Promise.race([
449
- new Response(proc.stdout).text().catch(() => ""),
574
+ // One merged read of BOTH streams, raced against the 5s cap: resolves when
575
+ // the process dies (a healthy probe answers and exits), else returns empty
576
+ // at the cap and the default form stands — a slow host is never punished.
577
+ // A generous keep (review P3): the tail-trimmed merge must not push the
578
+ // stdout %OS% answer out the front even behind unusual stderr volume.
579
+ const text = await Promise.race([
580
+ drainStderrTail([proc.stdout, proc.stderr], 8192),
450
581
  Bun.sleep(5_000).then(() => ""),
451
582
  ]);
452
583
  proc.kill();
453
- return /Windows_NT/i.test(output) ? "cmd" : "posix";
584
+ return { form: /Windows_NT/i.test(text) ? "cmd" : "posix", diagnostics: text };
454
585
  }
455
586
 
456
587
  /** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
@@ -463,7 +594,7 @@ async function probeHostShellFamily(
463
594
  async function probeWakeReady(
464
595
  base: string,
465
596
  timeoutMs: number,
466
- proc: ReturnType<typeof Bun.spawn>,
597
+ proc: HostSshProcess,
467
598
  ): Promise<boolean> {
468
599
  const deadline = Date.now() + timeoutMs;
469
600
  for (;;) {
@@ -499,9 +630,12 @@ async function probeWakeReady(
499
630
  * so diagnostics can arrive on either stream — both are drained and
500
631
  * concatenated. Resolves when the streams end (i.e. after the process is
501
632
  * dead) — only await once the proc has been killed or has exited. */
502
- function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep = 2000): Promise<string> {
633
+ function drainStderrTail(
634
+ streams: readonly (ReadableStream<Uint8Array> | number | undefined)[],
635
+ keep = 2000,
636
+ ): Promise<string> {
503
637
  return Promise.all(
504
- streams.map((stream) => new Response(stream).text().catch(() => "")),
638
+ streams.map((stream) => (typeof stream === "object" && stream !== null ? new Response(stream).text().catch(() => "") : "")),
505
639
  ).then((texts) => {
506
640
  const text = texts.join("").trim();
507
641
  return text.length > keep ? `...${text.slice(-keep)}` : text;
@@ -525,7 +659,7 @@ function drainStderrTail(streams: readonly ReadableStream<Uint8Array>[], keep =
525
659
  async function openSshTunnel(
526
660
  parts: { user?: string; host: string },
527
661
  options: { sshCommand?: readonly string[]; registryPath?: string },
528
- ): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
662
+ ): Promise<{ proc: HostSshProcess; base: string }> {
529
663
  const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
530
664
  // Tier 1 first: a detached mux master per origin (no-op when one already
531
665
  // holds the socket); the wake client below rides it when possible and
@@ -539,8 +673,25 @@ async function openSshTunnel(
539
673
  // Shell form (2026-09-20): probed up front via `echo %OS%` (language-
540
674
  // independent — see probeHostShellFamily), POSIX by default; the wrong-
541
675
  // shell ladder below remains the backstop for a lying or timed-out probe.
542
- let form: WakeShellForm = readCachedShellFamily(shellFamilyCachePath(options), target)
543
- ?? await probeHostShellFamily(options, target);
676
+ // Cold cache: the probe doubles as the first failure classifier
677
+ // (ADR-REM-008 §3) — its instant verdicts (refused / auth / hostkey /
678
+ // resolve failures) reject the origin here, before any ladder attempt;
679
+ // black-hole timeouts defer to the ladder's full ConnectTimeout budget.
680
+ // Warm cache (the probe is skipped): the ladder's first attempt carries
681
+ // the same classifier, so the classified verdicts cost at most one
682
+ // ConnectTimeout either way.
683
+ let form: WakeShellForm;
684
+ const cachedForm = readCachedShellFamily(shellFamilyCachePath(options), target);
685
+ if (cachedForm !== undefined) {
686
+ form = cachedForm;
687
+ } else {
688
+ const probed = await probeHostShellFamily(options, target);
689
+ const probeClass = classifyWakeFailure(probed.diagnostics);
690
+ if (probeClass !== undefined && !wakeFailureDefersToLadder(probeClass, probed.diagnostics)) {
691
+ throw new RemoteTransportError(wakeFailureMessage(probeClass, target, probed.diagnostics));
692
+ }
693
+ form = probed.form;
694
+ }
544
695
  const attempts = 3;
545
696
  for (let attempt = 1; attempt <= attempts; attempt++) {
546
697
  const localPort = await freePort();
@@ -564,7 +715,7 @@ async function openSshTunnel(
564
715
  target,
565
716
  wakeDoorCommand(remotePort, form),
566
717
  ];
567
- const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
718
+ const proc = spawnHostSsh(options, argv);
568
719
  const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
569
720
  if (await probeWakeReady(`http://127.0.0.1:${localPort}`, WAKE_READY_TIMEOUT_MS, proc)) {
570
721
  // Ground truth for the shell-family cache: the form that actually woke
@@ -579,6 +730,15 @@ async function openSshTunnel(
579
730
  // fast crash as a slow timeout.
580
731
  const diagnostics = (await stderrTail).trim();
581
732
  const exited = proc.exitCode !== null;
733
+ // Deterministic connect/auth verdicts never consume the ladder's
734
+ // remaining attempts (ADR-REM-008 §2): re-running against the same host
735
+ // reproduces them, so attempt 1's verdict IS the origin's verdict. The
736
+ // ladder stays for what it was built for — port-collision binds and
737
+ // not-ready timeouts.
738
+ const failureClass = exited ? classifyWakeFailure(diagnostics) : undefined;
739
+ if (failureClass !== undefined) {
740
+ throw new RemoteTransportError(wakeFailureMessage(failureClass, target, diagnostics));
741
+ }
582
742
  if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
583
743
  muxDisabled = true;
584
744
  }
@@ -604,7 +764,7 @@ async function openSshTunnel(
604
764
  throw new RemoteTransportError(
605
765
  exited
606
766
  ? `waking the ukp door on '${target}' failed: ${diagnostics}`
607
- : `the ukp door on '${target}' did not become ready within ${WAKE_READY_TIMEOUT_MS / 1000}s; check the host alias and key auth (BatchMode)`,
767
+ : wakeNotReadyMessage(target, WAKE_READY_TIMEOUT_MS),
608
768
  );
609
769
  }
610
770
  }
@@ -662,7 +822,7 @@ export interface RemoteTransportPool {
662
822
  export function createTransportPool(
663
823
  options: { sshCommand?: readonly string[]; registryPath?: string } = {},
664
824
  ): RemoteTransportPool {
665
- const tunnels = new Map<string, Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }>>();
825
+ const tunnels = new Map<string, Promise<{ proc: HostSshProcess; base: string }>>();
666
826
  return {
667
827
  async acquire(binding) {
668
828
  if (binding.kind !== "remote" || binding.url === undefined) {
@@ -1168,10 +1328,23 @@ function remoteRgCounts(raw: unknown): RgCountEntry[] | undefined {
1168
1328
  });
1169
1329
  }
1170
1330
 
1331
+ function remoteRgFiles(raw: unknown): RgFileEntry[] | undefined {
1332
+ if (!Array.isArray(raw)) return undefined;
1333
+ return raw.flatMap((entry): RgFileEntry[] => {
1334
+ if (typeof entry !== "object" || entry === null) return [];
1335
+ const file = entry as Record<string, unknown>;
1336
+ if (typeof file.path !== "string") return [];
1337
+ return [{ path: file.path, ...(typeof file.ukp_uri === "string" ? { ukp_uri: file.ukp_uri } : {}) }];
1338
+ });
1339
+ }
1340
+
1171
1341
  const RG_WIRE_STATUSES = new Set(["succeeded", "no_matches", "skipped", "failed", "interrupted", "cancelled"]);
1172
1342
 
1173
1343
  /** GET /v1/rg — single-endpoint outcome in the local RgEndpointOutcome
1174
- * vocabulary (provider is rg's external tier regardless of transport). */
1344
+ * vocabulary (provider is rg's external tier regardless of transport).
1345
+ * Files mode (ADR-RG-005) sends files=1 and omits query — an older serve
1346
+ * without files support answers its query-required usage error, which
1347
+ * lands here as a failed outcome carrying the server's message. */
1175
1348
  export async function remoteRg(
1176
1349
  binding: RegistryBinding,
1177
1350
  transport: RemoteTransportHandle,
@@ -1179,16 +1352,19 @@ export async function remoteRg(
1179
1352
  params: {
1180
1353
  query: string;
1181
1354
  limit: number;
1182
- glob?: string;
1355
+ globs?: readonly string[];
1183
1356
  type?: string;
1184
1357
  ignoreCase?: boolean;
1185
1358
  count?: boolean;
1359
+ files?: boolean;
1186
1360
  passthrough: readonly string[];
1187
1361
  },
1188
1362
  ): Promise<RemoteRgExecution> {
1189
1363
  const base = transport.base;
1190
- const search = new URLSearchParams({ query: params.query, limit: String(params.limit) });
1191
- if (params.glob !== undefined) search.set("glob", params.glob);
1364
+ const search = new URLSearchParams({ limit: String(params.limit) });
1365
+ if (params.files === true) search.set("files", "1");
1366
+ else search.set("query", params.query);
1367
+ for (const glob of params.globs ?? []) search.append("glob", glob);
1192
1368
  if (params.type !== undefined) search.set("type", params.type);
1193
1369
  if (params.ignoreCase === true) search.set("i", "1");
1194
1370
  if (params.count === true) search.set("count", "1");
@@ -1223,6 +1399,9 @@ export async function remoteRg(
1223
1399
  match.ukp_uri !== undefined ? { ...match, ukp_uri: reanchorRemoteUkpUri(match.ukp_uri, binding) } : match,
1224
1400
  );
1225
1401
  const counts = remoteRgCounts(entry.counts);
1402
+ const files = remoteRgFiles(entry.files)?.map((file) =>
1403
+ file.ukp_uri !== undefined ? { ...file, ukp_uri: reanchorRemoteUkpUri(file.ukp_uri, binding) } : file,
1404
+ );
1226
1405
  const warnings = Array.isArray(record.warnings) ? record.warnings.filter((item): item is string => typeof item === "string") : [];
1227
1406
  return {
1228
1407
  outcome: {
@@ -1232,6 +1411,7 @@ export async function remoteRg(
1232
1411
  ...(typeof entry.message === "string" ? { message: entry.message } : {}),
1233
1412
  ...(matches !== undefined ? { matches } : {}),
1234
1413
  ...(counts !== undefined ? { counts } : {}),
1414
+ ...(files !== undefined ? { files } : {}),
1235
1415
  ...(entry.truncated === true ? { truncated: true } : {}),
1236
1416
  },
1237
1417
  warnings,
@@ -6,6 +6,7 @@ import { resolveExternalToolCapability, EXTERNAL_PROVIDER } from "../config/exte
6
6
  import { localPathOf, readRegistry } from "../registry.ts";
7
7
  import { resolveScope } from "../scope.ts";
8
8
  import { isInsideRealRoot } from "../path-safety.ts";
9
+ import { toolRelayArgv, TOOL_NOT_FOUND_EXIT } from "../spawn-relay.ts";
9
10
 
10
11
  // rg capability (ADR-RG-001..004): an independent atomic
11
12
  // capability parallel to `search` — base lexical search over the endpoint's
@@ -15,6 +16,8 @@ import { isInsideRealRoot } from "../path-safety.ts";
15
16
  // Structured outcome per ADR 0021 — never rendered text, never exit codes.
16
17
 
17
18
  export interface RgRequest {
19
+ /** Search pattern (match/count modes). Empty only in files mode, where
20
+ * the parse layer guarantees it was omitted (ADR-RG-005 mutual exclusion). */
18
21
  query: string;
19
22
  limit: number;
20
23
  }
@@ -22,10 +25,15 @@ export interface RgRequest {
22
25
  export interface RgOptions {
23
26
  explicitEndpoints?: string[];
24
27
  global: boolean;
25
- glob?: string;
28
+ /** Ordered glob filters (repeatable top-level flag, ADR-RG-005: rg glob
29
+ * grammar lets a later glob negate with `!` — include+exclude combos). */
30
+ globs?: readonly string[];
26
31
  type?: string;
27
32
  ignoreCase?: boolean;
28
33
  count?: boolean;
34
+ /** Files enumeration mode (ADR-RG-005): true tree walk via `rg --files` —
35
+ * no content matching, so empty and binary files are visible. */
36
+ files?: boolean;
29
37
  /** Validated passthrough args (allowlist + scope protection, ADR-RG-002). */
30
38
  passthrough: readonly string[];
31
39
  /** Surface concern (propose precedent): the capability never consumes it —
@@ -72,6 +80,14 @@ export interface RgCountEntry {
72
80
  count: number;
73
81
  }
74
82
 
83
+ /** One enumerated file (files mode, ADR-RG-005): endpoint-relative path plus
84
+ * the durable slot key under the same containment-gated emission rule as
85
+ * matches (ADR 0019 stance). */
86
+ export interface RgFileEntry {
87
+ path: string;
88
+ ukp_uri?: string;
89
+ }
90
+
75
91
  export interface RgEndpointOutcome {
76
92
  name: string;
77
93
  provider: typeof EXTERNAL_PROVIDER;
@@ -91,13 +107,20 @@ export interface RgEndpointOutcome {
91
107
  matches?: RgMatch[];
92
108
  /** Count mode results (`--count`). */
93
109
  counts?: RgCountEntry[];
110
+ /** Files mode results (`--files`, sorted then capped). */
111
+ files?: RgFileEntry[];
94
112
  /** True when matches were truncated at the request limit. */
95
113
  truncated?: boolean;
96
114
  }
97
115
 
116
+ /** The run's output mode — drives tool argv, intake, and both presentation
117
+ * stages (envelope count_mode/files_mode and the human render branches). */
118
+ export type RgMode = "match" | "count" | "files";
119
+
98
120
  export interface RgResult {
99
121
  query: string;
100
122
  limit: number;
123
+ mode: RgMode;
101
124
  endpoints: RgEndpointOutcome[];
102
125
  warnings: string[];
103
126
  aggregate: RgAggregateStatus;
@@ -118,6 +141,10 @@ export class RgPlanningError extends Error {
118
141
  }
119
142
 
120
143
  export const RG_DEFAULT_LIMIT = 50;
144
+ /** Files-mode request limit default (ADR-RG-005): enumeration units are a
145
+ * single path line vs the 3-4 line match unit, so the same consumer-comfort
146
+ * calibration that set 50 for matches lands on 500 here. */
147
+ export const RG_FILES_DEFAULT_LIMIT = 500;
121
148
  export const RG_MAX_LIMIT = 1000;
122
149
 
123
150
  export function defaultRgCommand(): readonly string[] {
@@ -125,10 +152,23 @@ export function defaultRgCommand(): readonly string[] {
125
152
  }
126
153
 
127
154
  /** Base-tool availability probe (diagnose / provider resolver): true when a
128
- * ripgrep executable starts. */
155
+ * ripgrep executable starts. Memoized per process — PATH does not change
156
+ * mid-process, and server startup (the door's ServeInfo carries this field)
157
+ * must not pay the probe again per start; the win32 relay costs ~0.8s where
158
+ * the direct spawn was Defender-taxed ~5s (2026-09-22 finding, see
159
+ * spawn-relay.ts). The relay's not-found guard exits TOOL_NOT_FOUND_EXIT,
160
+ * which no real ripgrep invocation produces (rg exits 0/1/2 only). */
161
+ let rgAvailabilityMemo: boolean | undefined;
162
+
129
163
  export function rgExecutableAvailable(): boolean {
130
- const result = spawnSync("rg", ["--version"], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
131
- return !result.error;
164
+ if (rgAvailabilityMemo === undefined) {
165
+ const probeArgv = process.platform === "win32"
166
+ ? toolRelayArgv(["rg", "--version"], { notFoundGuard: true })
167
+ : ["rg", "--version"];
168
+ const result = spawnSync(probeArgv[0]!, probeArgv.slice(1), { encoding: "utf8", windowsHide: true, timeout: 5_000 });
169
+ rgAvailabilityMemo = !result.error && result.status !== TOOL_NOT_FOUND_EXIT;
170
+ }
171
+ return rgAvailabilityMemo;
132
172
  }
133
173
 
134
174
  export function rgTimeoutMs(): number {
@@ -142,8 +182,9 @@ export function rgTimeoutMs(): number {
142
182
  // Passthrough validation (ADR-RG-002): allowlist + scope protection.
143
183
  // ---------------------------------------------------------------------------
144
184
 
145
- /** Value flags consume the next token (or an attached value: `-C2`). */
146
- const PASSTHROUGH_VALUE_FLAGS = new Set(["-A", "-B", "-C", "-m", "--max-count", "-g", "--glob", "-t", "--type", "--max-filesize"]);
185
+ /** Value flags consume the next token (or an attached value: `-C2`). `--iglob`
186
+ * joined with ADR-RG-005 (case-insensitive glob variant, probe friction 2/8). */
187
+ const PASSTHROUGH_VALUE_FLAGS = new Set(["-A", "-B", "-C", "-m", "--max-count", "-g", "--glob", "--iglob", "-t", "--type", "--max-filesize"]);
147
188
  const PASSTHROUGH_BOOL_FLAGS = new Set(["-i", "--ignore-case", "-S", "--smart-case", "-s", "--case-sensitive", "-w", "--word-regexp", "-F", "--fixed-strings", "-v", "--invert-match", "-U", "--multiline", "--no-ignore", "--hidden", "--no-messages", "--column", "--no-heading"]);
148
189
 
149
190
  /** Validates passthrough args: allowlist membership + scope protection.
@@ -234,13 +275,15 @@ function toUkpUri(endpointName: string, path: string): string {
234
275
  * provider-location mapping. A miss silently drops the `ukp_uri` field
235
276
  * (the match and its `path` stay): rg output is tool output, not user
236
277
  * error. Output-side filtering is the second line of defense; the
237
- * passthrough allowlist (no path operands) is the first. */
238
- function ukpUriIfInside(endpointFolder: string, endpointName: string, path: string): string | undefined {
278
+ * passthrough allowlist (no path operands) is the first. The folder's
279
+ * realpath is passed in pre-resolved (per endpoint, not per entry — review
280
+ * 2026-09-22: per-file resolution paid 2 realpaths/file on exactly the
281
+ * win32 Defender-taxed path); an unresolvable folder emits nothing. */
282
+ function ukpUriIfInside(folderReal: string | undefined, endpointFolder: string, endpointName: string, path: string): string | undefined {
283
+ if (folderReal === undefined) return undefined;
239
284
  const absolute = resolve(endpointFolder, ...path.replace(/\\/g, "/").split("/"));
240
- let folderReal: string;
241
285
  let targetReal: string;
242
286
  try {
243
- folderReal = realpathSync(endpointFolder);
244
287
  targetReal = realpathSync(absolute);
245
288
  } catch {
246
289
  return undefined;
@@ -259,6 +302,16 @@ interface RgJsonEvent {
259
302
  }
260
303
 
261
304
  function rgToolArgs(parsed: ParsedRg): string[] {
305
+ if (parsed.options.files) {
306
+ // Files mode (ADR-RG-005): true tree walk — rg lists paths without
307
+ // reading content, so empty and binary files are visible. No `-e`:
308
+ // the parse layer guarantees no pattern rides along.
309
+ const args: string[] = ["--files", "--no-messages"];
310
+ for (const glob of parsed.options.globs ?? []) args.push("--glob", glob);
311
+ if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
312
+ args.push(...parsed.options.passthrough);
313
+ return args;
314
+ }
262
315
  const args: string[] = ["--no-heading", "--no-messages"];
263
316
  if (parsed.options.count) {
264
317
  args.push("--count");
@@ -266,7 +319,7 @@ function rgToolArgs(parsed: ParsedRg): string[] {
266
319
  args.push("--json");
267
320
  }
268
321
  if (parsed.options.ignoreCase) args.push("-i");
269
- if (parsed.options.glob !== undefined) args.push("--glob", parsed.options.glob);
322
+ for (const glob of parsed.options.globs ?? []) args.push("--glob", glob);
270
323
  if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
271
324
  args.push(...parsed.options.passthrough);
272
325
  args.push("-e", parsed.request.query);
@@ -280,6 +333,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
280
333
  validateRgPassthrough(parsed.options.passthrough);
281
334
  const { plan, warnings } = planRg(parsed, context);
282
335
  const command = context.rgCommand ?? defaultRgCommand();
336
+ const mode: RgMode = parsed.options.files === true ? "files" : parsed.options.count === true ? "count" : "match";
283
337
  const endpoints: RgEndpointOutcome[] = [];
284
338
  let succeeded = false;
285
339
  let failed = false;
@@ -293,13 +347,36 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
293
347
  continue;
294
348
  }
295
349
 
350
+ // Lazy once-per-endpoint folder realpath for the uri emission rule: the
351
+ // folder cannot change mid-run, and a skip/fail path never pays for it.
352
+ // `null` marks "not attempted yet" vs `undefined` = "attempted, failed"
353
+ // (a failed resolution emits no uris without re-attempting per entry).
354
+ let endpointFolderReal: string | undefined | null = null;
355
+ const folderRealpath = (): string | undefined => {
356
+ if (endpointFolderReal === null) {
357
+ try {
358
+ endpointFolderReal = realpathSync(endpoint.folder);
359
+ } catch {
360
+ endpointFolderReal = undefined;
361
+ }
362
+ }
363
+ return endpointFolderReal;
364
+ };
365
+
296
366
  // Explicit search root: with no path operand rg searches stdin instead
297
367
  // of the cwd whenever stdin is not a tty (ISSUE-013 root cause,
298
368
  // confirmed on the ali repro host: spawned rg read an empty pipe and
299
369
  // exited 1 with zero traversal; tty and /dev/null stdin walk the cwd).
300
370
  // "." keeps rg's rendered paths endpoint-relative; the "./" prefix is
301
- // stripped at both intakes below.
302
- const result = spawnSync(command[0]!, [...command.slice(1), ...rgToolArgs(parsed), "."], {
371
+ // stripped at both intakes below. Win32: the real-tool invocation routes
372
+ // through the spawn relay (Defender taxes the direct bun-to-rg.exe edge
373
+ // ~5s — 2026-09-22 finding, see spawn-relay.ts); the injected test
374
+ // command is never wrapped, and the relay's not-found guard maps a
375
+ // missing rg onto the same skip path the spawn error always took.
376
+ const runArgv = process.platform === "win32" && context.rgCommand === undefined
377
+ ? toolRelayArgv([...command, ...rgToolArgs(parsed), "."], { notFoundGuard: true })
378
+ : [command[0]!, ...command.slice(1), ...rgToolArgs(parsed), "."];
379
+ const result = spawnSync(runArgv[0]!, runArgv.slice(1), {
303
380
  cwd: endpoint.folder,
304
381
  encoding: "utf8",
305
382
  windowsHide: true,
@@ -307,7 +384,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
307
384
  timeout: rgTimeoutMs(),
308
385
  });
309
386
 
310
- if (result.error) {
387
+ if (result.error || result.status === TOOL_NOT_FOUND_EXIT) {
311
388
  // ADR-RG-003/R-7: a missing base tool is a degradation, not a fault —
312
389
  // skipped + warning, the run continues with other endpoints.
313
390
  warnings.push(`endpoint '${endpoint.name}' rg unavailable: rg executable is not available`);
@@ -340,6 +417,42 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
340
417
  continue;
341
418
  }
342
419
 
420
+ if (mode === "files") {
421
+ // Plain path-per-line intake. The full list is sorted BEFORE the
422
+ // client-side cap so truncation yields a deterministic window (the
423
+ // first N paths in lexicographic order, ADR-RG-005) rather than an
424
+ // arbitrary subset of rg's parallel-walk order.
425
+ const paths = (result.stdout ?? "")
426
+ .split(/\r?\n/)
427
+ .filter((line) => line.length > 0)
428
+ .map((line) => line.replace(/\\/g, "/").replace(/^\.\//, ""))
429
+ .sort();
430
+ const files: RgFileEntry[] = [];
431
+ let truncated = false;
432
+ for (const path of paths) {
433
+ if (files.length >= parsed.request.limit) {
434
+ truncated = true;
435
+ break;
436
+ }
437
+ const entry: RgFileEntry = { path };
438
+ const uri = ukpUriIfInside(folderRealpath(), endpoint.folder, endpoint.name, path);
439
+ if (uri !== undefined) entry.ukp_uri = uri;
440
+ files.push(entry);
441
+ }
442
+ if (files.length === 0 && !truncated) {
443
+ // An empty enumeration (empty folder or everything filtered out) is
444
+ // a successful result — same D-036 stance as a no-match search.
445
+ succeeded = true;
446
+ outcome.status = "no_matches";
447
+ } else {
448
+ succeeded = true;
449
+ outcome.status = "succeeded";
450
+ outcome.files = files;
451
+ if (truncated) outcome.truncated = true;
452
+ }
453
+ continue;
454
+ }
455
+
343
456
  if (parsed.options.count) {
344
457
  const counts: RgCountEntry[] = [];
345
458
  for (const line of (result.stdout ?? "").split(/\r?\n/)) {
@@ -386,7 +499,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
386
499
  const firstLine = text.split(/\r?\n/)[0] ?? "";
387
500
  if (firstLine.length > 0) entry.text = firstLine.length > 240 ? `${firstLine.slice(0, 240)}...` : firstLine;
388
501
  }
389
- const uri = ukpUriIfInside(endpoint.folder, endpoint.name, entry.path);
502
+ const uri = ukpUriIfInside(folderRealpath(), endpoint.folder, endpoint.name, entry.path);
390
503
  if (uri !== undefined) entry.ukp_uri = uri;
391
504
  matches.push(entry);
392
505
  }
@@ -406,6 +519,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
406
519
  return {
407
520
  query: parsed.request.query,
408
521
  limit: parsed.request.limit,
522
+ mode,
409
523
  endpoints,
410
524
  warnings,
411
525
  aggregate: interrupted
@@ -426,35 +540,41 @@ export interface RgEnvelope {
426
540
  schema: "ukp.rg.v1";
427
541
  command: "rg";
428
542
  capability: "rg";
429
- query: string;
543
+ /** Omitted in files mode (ADR-RG-005): there is no pattern to echo. */
544
+ query?: string;
430
545
  limit: number;
431
546
  count_mode: boolean;
547
+ files_mode: boolean;
432
548
  endpoints: Array<{
433
549
  name: string;
434
550
  provider: string;
435
551
  status: string;
436
552
  match_count?: number;
553
+ file_count?: number;
437
554
  truncated?: boolean;
438
555
  message?: string;
439
556
  matches?: RgMatch[];
440
557
  counts?: RgCountEntry[];
558
+ files?: RgFileEntry[];
441
559
  }>;
442
560
  warnings: string[];
443
561
  }
444
562
 
445
- export function projectRgEnvelope(result: RgResult, countMode: boolean): RgEnvelope {
563
+ export function projectRgEnvelope(result: RgResult): RgEnvelope {
446
564
  return {
447
565
  schema: "ukp.rg.v1",
448
566
  command: "rg",
449
567
  capability: "rg",
450
- query: result.query,
568
+ ...(result.mode === "files" ? {} : { query: result.query }),
451
569
  limit: result.limit,
452
- count_mode: countMode,
570
+ count_mode: result.mode === "count",
571
+ files_mode: result.mode === "files",
453
572
  endpoints: result.endpoints.map((endpoint) => ({
454
573
  name: endpoint.name,
455
574
  provider: endpoint.provider,
456
575
  status: endpoint.status ?? "failed",
457
576
  ...(endpoint.matches !== undefined ? { match_count: endpoint.matches.length, matches: endpoint.matches } : {}),
577
+ ...(endpoint.files !== undefined ? { file_count: endpoint.files.length, files: endpoint.files } : {}),
458
578
  ...(endpoint.counts !== undefined ? { counts: endpoint.counts } : {}),
459
579
  ...(endpoint.truncated !== undefined ? { truncated: endpoint.truncated } : {}),
460
580
  ...(endpoint.message !== undefined ? { message: endpoint.message } : {}),
@@ -480,7 +600,14 @@ export function renderRgHuman(result: RgResult): RgHumanView {
480
600
  continue;
481
601
  }
482
602
  if (endpoint.status === "no_matches") {
483
- lines.push("(no matches)");
603
+ lines.push(result.mode === "files" ? "(no files)" : "(no matches)");
604
+ continue;
605
+ }
606
+ if (endpoint.files !== undefined) {
607
+ // Exhibit form (probe 20260922, zero misreads): one line per file,
608
+ // the durable uri when containment allows it, the raw path otherwise.
609
+ for (const file of endpoint.files) lines.push(file.ukp_uri ?? file.path);
610
+ if (endpoint.truncated) lines.push(`(truncated at the result limit ${result.limit}; raise --limit)`);
484
611
  continue;
485
612
  }
486
613
  if (endpoint.counts !== undefined) {
package/src/cli.ts CHANGED
@@ -24,7 +24,7 @@ import { executeSearchCommand, SEARCH_SPEC } from "./commands/search.ts";
24
24
  export { renderSearchHelp } from "./commands/search.ts";
25
25
  export { renderDiagnoseHelp } from "./commands/diagnose.ts";
26
26
  export { renderReadHelp } from "./commands/read.ts";
27
- export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
27
+ export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRgGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
28
28
  export { renderInitHelp, renderInitServiceHelp } from "./commands/init.ts";
29
29
  export { renderInspectHelp } from "./commands/inspect.ts";
30
30
  export { renderUpdateHelp } from "./commands/update.ts";
@@ -12,6 +12,8 @@ Current model:
12
12
  Owner path over SSH (zero ceremony - nothing to start on the host):
13
13
  1. Prerequisites (once): the host accepts your SSH key and has ukp installed the usual way
14
14
  ssh <host> ukp --help
15
+ Peek at the door before importing: ssh <host> ukp list shows the endpoint
16
+ names and capabilities the host declares
15
17
 
16
18
  2. On the consuming machine, import the door (the first call wakes it)
17
19
  ukp register --url ssh://<host>
@@ -45,7 +47,7 @@ Use this when you deliberately want to expose only one endpoint instead of the w
45
47
  ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
46
48
 
47
49
  Name and identity:
48
- - register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken).
50
+ - register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken). That document is public - curl it before registering to preview the endpoint names and capabilities the door declares (self-signed doors: curl -k).
49
51
  - --endpoint <name> on register asserts the expected name; if discovery declares a different name, registration fails.
50
52
  - A host door can import everything, --select a comma-separated subset, or --endpoint exactly one endpoint.
51
53
  - Self-signed HTTPS certificates are pinned on first registration; compare the pinned fingerprint with the serve banner on untrusted networks.
@@ -0,0 +1,35 @@
1
+ UKP rg quickstart
2
+
3
+ Goal: search or enumerate a Service's own files with ripgrep - no index, no declaration, real-time.
4
+
5
+ Model:
6
+ - rg is the lexical baseline: it scans files at call time, so it works on every registered endpoint. 'ukp search' is the indexed path and needs the Service to declare a provider.
7
+ - Output is ukp:// references that 'ukp read' consumes directly.
8
+ - Three modes: match (default), --count (per-file totals), --files (enumeration).
9
+
10
+ 1. Search
11
+ ukp rg --endpoint <name> "pattern"
12
+ -i is case-insensitive; --glob narrows by name; --limit raises the cap (default 50 matches).
13
+
14
+ 2. Enumerate files (no pattern)
15
+ ukp rg --endpoint <name> --files
16
+ A true tree walk, not a content match: empty and binary files are listed too.
17
+ Narrow by name with --glob, by kind with --type. Output is sorted; the
18
+ cap defaults to 500 (--limit to change).
19
+
20
+ 3. Glob semantics
21
+ --glob follows ripgrep/gitignore rules: a glob without "/" matches the
22
+ file name at any depth ("*.md"); a glob containing "/" anchors to the
23
+ endpoint-relative path ("docs/*"). Matching is case-sensitive; pass
24
+ -- --iglob "<glob>" after '--' for a case-insensitive variant.
25
+ --glob repeats: a leading "!" excludes, so "*.md" plus "!docs/*" is
26
+ markdown outside docs/.
27
+
28
+ 4. Visibility (every rg mode)
29
+ Hidden files (dot-prefixed, including the .ukp/ wiring) are skipped by
30
+ default. Add -- --hidden after '--' to include them; -uu also lifts
31
+ .gitignore rules. 'ukp read' can address hidden files directly either way.
32
+
33
+ Remember:
34
+ - Exact flags and the passthrough allowlist live in 'ukp rg --help'.
35
+ - Remote endpoints behave the same: one command drives local and door-registered endpoints alike.
@@ -20,10 +20,11 @@ const GUIDE_CONTENT_URL = new URL("./guide-content/", import.meta.url);
20
20
  * `ukp guide --help` render the same summaries (blank-agent sweep 2026-09-11:
21
21
  * the inversion of root help describing guides better than guide's own help
22
22
  * was a confirmed doc-gap). */
23
- export const GUIDE_TOPICS: ReadonlyArray<readonly [topic: string, summary: string]> = [
23
+ export const GUIDE_TOPICS: ReadonlyArray<readonly [string, string]> = [
24
24
  ["service", "first Service setup with provider-free nav/read/rg baseline"],
25
25
  ["service qmd", "provider setup for the default QMD provider"],
26
26
  ["client", "use registered Services by default from a workspace; set your default scope here"],
27
+ ["rg", "lexical search and file enumeration: modes, glob semantics, visibility tiers"],
27
28
  ["remote", "serve and consume remote endpoints with a host door, ssh, or TLS"],
28
29
  ["propose", "submit idempotent change proposals to a Service"],
29
30
  ];
@@ -38,7 +39,7 @@ export const GUIDE_SPEC: UkpCommandSpec = {
38
39
  description: "Show short operational guides.",
39
40
  usage: "<topic> [subtopic]",
40
41
  arguments: [
41
- { name: "topic", required: true, help: "guide topic: service | service qmd | client | remote | propose" },
42
+ { name: "topic", required: true, help: "guide topic: service | service qmd | client | rg | remote | propose" },
42
43
  { name: "subtopic", help: "provider subtopic for a topic, e.g. 'qmd' as in 'ukp guide service qmd'" },
43
44
  ],
44
45
  strictArguments: true,
@@ -72,17 +73,19 @@ export function executeGuideCommand(args: readonly string[]): GuideCommandResult
72
73
  return { exitCode: 0, stdout: renderServiceGuide(), stderr: "" };
73
74
  }
74
75
  if (subtopic) {
75
- throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, remote, propose`);
76
+ throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, rg, remote, propose`);
76
77
  }
77
78
  switch (topic) {
78
79
  case "client":
79
80
  return { exitCode: 0, stdout: renderClientGuide(), stderr: "" };
81
+ case "rg":
82
+ return { exitCode: 0, stdout: renderRgGuide(), stderr: "" };
80
83
  case "remote":
81
84
  return { exitCode: 0, stdout: renderRemoteGuide(), stderr: "" };
82
85
  case "propose":
83
86
  return { exitCode: 0, stdout: renderProposeGuide(), stderr: "" };
84
87
  default:
85
- throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, remote, propose`);
88
+ throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, rg, remote, propose`);
86
89
  }
87
90
  } catch (error) {
88
91
  if (error instanceof HelpRequestError) {
@@ -115,6 +118,10 @@ export function renderClientGuide(): string {
115
118
  return readGuideContent("client.txt");
116
119
  }
117
120
 
121
+ export function renderRgGuide(): string {
122
+ return readGuideContent("rg.txt");
123
+ }
124
+
118
125
  export function renderRemoteGuide(): string {
119
126
  return readGuideContent("remote.txt");
120
127
  }
@@ -3,6 +3,7 @@ import {
3
3
  projectRgEnvelope,
4
4
  renderRgHuman,
5
5
  RG_DEFAULT_LIMIT,
6
+ RG_FILES_DEFAULT_LIMIT,
6
7
  RG_MAX_LIMIT,
7
8
  RgPlanningError,
8
9
  RgUsageError,
@@ -11,6 +12,7 @@ import {
11
12
  type RgAggregateStatus,
12
13
  type RgContext,
13
14
  type RgEndpointOutcome,
15
+ type RgMode,
14
16
  type RgResult,
15
17
  } from "../capabilities/rg.ts";
16
18
  import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
@@ -50,17 +52,19 @@ export const RG_SPEC: UkpCommandSpec = {
50
52
  summary: "grep raw endpoint files with ripgrep (no index or declaration needed; results as ukp:// references)",
51
53
  group: "endpoint",
52
54
  description:
53
- "Run base lexical search (ripgrep) across one or more Service endpoints. "
55
+ "Run base lexical search (ripgrep) across one or more Service endpoints, "
56
+ + "or enumerate their files with --files. "
54
57
  + "Available on every registered endpoint by default; results are shaped into ukp:// references that 'ukp read' consumes directly. "
55
58
  + "For indexed/semantic search use 'ukp search'.",
56
- usage: "[--endpoint <name> ... | -g] <pattern> [--limit <1-1000>] [--count] [--glob <glob>] [--type <type>] [-i] [-- <rg flags>]",
57
- arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell" }],
59
+ usage: "[--endpoint <name> ... | -g] (<pattern> | --files) [--limit <1-1000>] [--count] [--glob <glob> ...] [--type <type>] [-i] [-- <rg flags>]",
60
+ arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell (omit with --files)" }],
58
61
  options: [
59
62
  { flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
60
63
  { flags: "-g", help: "search every endpoint in the Host Registry; takes no value" },
61
- { flags: "--limit <1-1000>", help: `maximum matches per run (default: ${RG_DEFAULT_LIMIT})` },
64
+ { flags: "--limit <1-1000>", help: `maximum results per run (default: ${RG_DEFAULT_LIMIT}; ${RG_FILES_DEFAULT_LIMIT} with --files)` },
62
65
  { flags: "--count", help: "count mode: per-file match counts instead of matches" },
63
- { flags: "--glob <glob>", help: "glob filter passed to rg (e.g. \"*.md\")" },
66
+ { flags: "--files", help: "files mode: enumerate files by tree walk with no pattern (empty and binary files included; combine with --glob/--type)" },
67
+ { flags: "--glob <glob>", help: "glob filter passed to rg (e.g. \"*.md\"); repeat to combine - a later glob may negate with !", multi: true },
64
68
  { flags: "--type <type>", help: "file type filter passed to rg (e.g. md, py)" },
65
69
  { flags: "-i", help: "case-insensitive search" },
66
70
  { flags: "--json", help: "emit the structured response envelope" },
@@ -69,7 +73,8 @@ export const RG_SPEC: UkpCommandSpec = {
69
73
  "",
70
74
  "Passthrough:",
71
75
  " After '--', rg native flags are passed through on an allowlist",
72
- " (-A/-B/-C/-m/--glob/--type/--max-filesize and common boolean flags).",
76
+ " (-A/-B/-C/-m/--glob/--iglob/--type/--max-filesize and common boolean",
77
+ " flags).",
73
78
  " Path operands and output-changing flags (--json, -r, --pre, --config)",
74
79
  " are rejected: the endpoint selector owns scope, UKP owns the output.",
75
80
  "",
@@ -84,6 +89,12 @@ export const RG_SPEC: UkpCommandSpec = {
84
89
  " 'ukp search' covers indexed search where the Service declares a search",
85
90
  " capability provider.",
86
91
  "",
92
+ "Visibility:",
93
+ " Every rg mode skips hidden files (dot-prefixed, including the .ukp/",
94
+ " wiring) by default; pass -- --hidden after '--' to include them",
95
+ " (-uu additionally lifts .gitignore rules). --files lists every",
96
+ " non-hidden file, including empty and binary ones.",
97
+ "",
87
98
  ].join("\n"),
88
99
  };
89
100
 
@@ -92,7 +103,8 @@ interface RgCommandOptions extends Record<string, unknown> {
92
103
  g?: boolean;
93
104
  limit?: string;
94
105
  count?: boolean;
95
- glob?: string;
106
+ files?: boolean;
107
+ glob?: string[];
96
108
  type?: string;
97
109
  i?: boolean;
98
110
  json?: boolean;
@@ -102,10 +114,13 @@ export function parseRgArgs(args: readonly string[]): ParsedRg {
102
114
  const parsed = parseKitArgs<RgCommandOptions>(RG_SPEC, args);
103
115
  const explicit: string[] = [];
104
116
  const warnings: string[] = [];
117
+ const files = parsed.options.files === true;
105
118
 
106
119
  // Command-side semantic checks, in the pre-kit order: limit validation,
107
- // duplicate warnings, excess positional, emptiness, conflict.
108
- let limit = RG_DEFAULT_LIMIT;
120
+ // duplicate warnings, excess positional, mode conflicts, pattern rule,
121
+ // scope conflict. Files mode (ADR-RG-005) relaxes the pattern requirement
122
+ // and rejects combinations that would silently no-op.
123
+ let limit = files ? RG_FILES_DEFAULT_LIMIT : RG_DEFAULT_LIMIT;
109
124
  if (parsed.options.limit !== undefined) {
110
125
  if (!/^[0-9]+$/.test(parsed.options.limit)) throw new KitUsageError("--limit must be a decimal integer");
111
126
  limit = Number(parsed.options.limit);
@@ -125,20 +140,34 @@ export function parseRgArgs(args: readonly string[]): ParsedRg {
125
140
  `unexpected argument '${unexpected}'; rg accepts exactly one pattern. Use '--endpoint <name>' to select an endpoint; '-g' takes no value.`,
126
141
  );
127
142
  }
128
- if (pattern === undefined || pattern.length === 0) throw new KitUsageError("rg pattern must be non-empty");
143
+ if (files && parsed.options.count === true) {
144
+ throw new KitUsageError("--files and --count cannot be used together (pick one output mode)");
145
+ }
146
+ if (files && parsed.options.i === true) {
147
+ throw new KitUsageError("--files cannot be combined with -i: there is no pattern to match; pass '-- --iglob <glob>' for a case-insensitive glob filter");
148
+ }
149
+ if (files) {
150
+ if (pattern !== undefined) {
151
+ throw new KitUsageError(`unexpected argument '${pattern}'; --files takes no pattern - use --glob to filter by name`);
152
+ }
153
+ } else if (pattern === undefined || pattern.length === 0) {
154
+ throw new KitUsageError("rg pattern must be non-empty (or pass --files to enumerate files without a pattern)");
155
+ }
129
156
  if (parsed.options.g && explicit.length > 0) {
130
157
  throw new KitUsageError("--endpoint and -g cannot be used together");
131
158
  }
132
159
 
160
+ const globs = parsed.options.glob ?? [];
133
161
  return {
134
- request: { query: pattern, limit },
162
+ request: { query: files ? "" : pattern!, limit },
135
163
  options: {
136
164
  explicitEndpoints: explicit.length > 0 ? explicit : undefined,
137
165
  global: parsed.options.g ?? false,
138
- ...(parsed.options.glob === undefined ? {} : { glob: parsed.options.glob }),
166
+ ...(globs.length > 0 ? { globs } : {}),
139
167
  ...(parsed.options.type === undefined ? {} : { type: parsed.options.type }),
140
168
  ...(parsed.options.i === true ? { ignoreCase: true } : {}),
141
169
  ...(parsed.options.count === true ? { count: true } : {}),
170
+ ...(files ? { files: true } : {}),
142
171
  ...(parsed.options.json === true ? { json: true } : {}),
143
172
  passthrough: [],
144
173
  },
@@ -238,7 +267,7 @@ function renderRgCommandResult(parsed: ParsedRg, result: RgResult): RgCommandRes
238
267
  if (parsed.options.json === true) {
239
268
  return {
240
269
  exitCode: RG_EXIT_BY_AGGREGATE[result.aggregate],
241
- stdout: `${JSON.stringify(projectRgEnvelope(result, parsed.options.count === true), null, 2)}\n`,
270
+ stdout: `${JSON.stringify(projectRgEnvelope(result), null, 2)}\n`,
242
271
  stderr: view.diagnostics,
243
272
  };
244
273
  }
@@ -305,10 +334,11 @@ async function executeMixedRg(
305
334
  const execution = await remoteRg(binding, transport, token, {
306
335
  query: parsed.request.query,
307
336
  limit: parsed.request.limit,
308
- ...(parsed.options.glob !== undefined ? { glob: parsed.options.glob } : {}),
337
+ ...(parsed.options.globs !== undefined ? { globs: parsed.options.globs } : {}),
309
338
  ...(parsed.options.type !== undefined ? { type: parsed.options.type } : {}),
310
339
  ...(parsed.options.ignoreCase === true ? { ignoreCase: true } : {}),
311
340
  ...(parsed.options.count === true ? { count: true } : {}),
341
+ ...(parsed.options.files === true ? { files: true } : {}),
312
342
  passthrough: parsed.options.passthrough,
313
343
  });
314
344
  remoteOutcomes.set(binding.name, execution.outcome);
@@ -343,6 +373,7 @@ async function executeMixedRg(
343
373
  return renderRgCommandResult(parsed, {
344
374
  query: parsed.request.query,
345
375
  limit: parsed.request.limit,
376
+ mode: (parsed.options.files === true ? "files" : parsed.options.count === true ? "count" : "match") as RgMode,
346
377
  endpoints,
347
378
  warnings,
348
379
  aggregate,
package/src/server.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  runRg,
34
34
  projectRgEnvelope,
35
35
  RG_DEFAULT_LIMIT,
36
+ RG_FILES_DEFAULT_LIMIT,
36
37
  RG_MAX_LIMIT,
37
38
  validateRgPassthrough,
38
39
  RgUsageError,
@@ -463,18 +464,44 @@ function parseNavParams(
463
464
  }
464
465
 
465
466
  /** Wire rg params → ParsedRg with serve-fixed scope. Params are flat (no
466
- * argv reconstruction needed): query/limit/glob/type/i/count map onto
467
+ * argv reconstruction needed): query/limit/glob/type/i/count/files map onto
467
468
  * ParsedRg directly and passthrough re-validates against the same
468
- * allowlist (ADR-RG-002) before any endpoint work starts. */
469
+ * allowlist (ADR-RG-002) before any endpoint work starts. Files mode
470
+ * (ADR-RG-005) mirrors the CLI parse layer's mutual exclusions so the wire
471
+ * keeps the same strictness as query/limit. */
469
472
  function parseRgParams(
470
473
  endpointName: string,
471
474
  url: URL,
472
475
  ): { parsed: ParsedRg } | { error: string } {
476
+ // Boolean params are strictly "1"/"0" — same strictness as query/limit, so
477
+ // a hand-crafted `i=true` is a visible usage error, not a silent mode miss.
478
+ const booleanOf = (name: string): boolean | { error: string } => {
479
+ const raw = url.searchParams.get(name);
480
+ if (raw === null) return false;
481
+ if (raw === "1") return true;
482
+ if (raw === "0") return false;
483
+ return { error: `'${name}' must be 1 or 0` };
484
+ };
485
+ const files = booleanOf("files");
486
+ if (typeof files === "object") return files;
487
+ const ignoreCase = booleanOf("i");
488
+ if (typeof ignoreCase === "object") return ignoreCase;
489
+ const count = booleanOf("count");
490
+ if (typeof count === "object") return count;
473
491
  const query = url.searchParams.get("query");
474
- if (query === null || query.length === 0) {
475
- return { error: "'query' is required and must be a non-empty pattern" };
492
+ if (files && count) {
493
+ return { error: "'files' and 'count' cannot be used together (pick one output mode)" };
494
+ }
495
+ if (files && ignoreCase) {
496
+ return { error: "'files' cannot be combined with 'i': pass an --iglob passthrough arg for a case-insensitive glob filter" };
497
+ }
498
+ if (files && query !== null) {
499
+ return { error: "'files' takes no 'query' - use 'glob' to filter by name" };
476
500
  }
477
- let limit = RG_DEFAULT_LIMIT;
501
+ if (!files && (query === null || query.length === 0)) {
502
+ return { error: "'query' is required and must be a non-empty pattern (or pass files=1 for enumeration mode)" };
503
+ }
504
+ let limit = files ? RG_FILES_DEFAULT_LIMIT : RG_DEFAULT_LIMIT;
478
505
  const rawLimit = url.searchParams.get("limit");
479
506
  if (rawLimit !== null) {
480
507
  if (!/^[0-9]+$/.test(rawLimit)) return { error: "'limit' must be a decimal integer" };
@@ -490,31 +517,19 @@ function parseRgParams(
490
517
  if (error instanceof RgUsageError) return { error: error.message };
491
518
  throw error;
492
519
  }
493
- // Boolean params are strictly "1"/"0" — same strictness as query/limit, so
494
- // a hand-crafted `i=true` is a visible usage error, not a silent mode miss.
495
- const booleanOf = (name: string): boolean | { error: string } => {
496
- const raw = url.searchParams.get(name);
497
- if (raw === null) return false;
498
- if (raw === "1") return true;
499
- if (raw === "0") return false;
500
- return { error: `'${name}' must be 1 or 0` };
501
- };
502
- const ignoreCase = booleanOf("i");
503
- if (typeof ignoreCase === "object") return ignoreCase;
504
- const count = booleanOf("count");
505
- if (typeof count === "object") return count;
506
- const glob = url.searchParams.get("glob");
520
+ const globs = url.searchParams.getAll("glob");
507
521
  const type = url.searchParams.get("type");
508
522
  return {
509
523
  parsed: {
510
- request: { query, limit },
524
+ request: { query: files ? "" : query!, limit },
511
525
  options: {
512
526
  explicitEndpoints: [endpointName],
513
527
  global: false,
514
- ...(glob !== null ? { glob } : {}),
528
+ ...(globs.length > 0 ? { globs } : {}),
515
529
  ...(type !== null ? { type } : {}),
516
530
  ...(ignoreCase ? { ignoreCase: true } : {}),
517
531
  ...(count ? { count: true } : {}),
532
+ ...(files ? { files: true } : {}),
518
533
  passthrough,
519
534
  },
520
535
  warnings: [],
@@ -1170,7 +1185,7 @@ function handleRg(url: URL, request: Request, config: ServeConfig, endpointName:
1170
1185
  currentDirectory: config.currentDirectory,
1171
1186
  registryPath: config.registryPath,
1172
1187
  });
1173
- return jsonResponse(projectRgEnvelope(result, parsed.parsed.options.count === true));
1188
+ return jsonResponse(projectRgEnvelope(result));
1174
1189
  }
1175
1190
 
1176
1191
  /** PUT /v1/propose/{id} (W8 / ADR-REM-005): the write face. Body = the
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Win32 tool-spawn relay (2026-09-22 finding, ukp_remote TODO
3
+ * 20260922-win32-ssh-spawn-tax): Windows Defender holds the DIRECT
4
+ * bun-to-<tool>.exe child edge for seconds per spawn — measured 9.7-10.7s for
5
+ * ssh.exe and ~5.4s for rg.exe across stdio modes, windowsHide, and absolute
6
+ * paths, while the same binaries start in 0.16-0.26s from bash and in
7
+ * 0.55-0.8s from bun through ANY intermediary parent (cmd, node, powershell).
8
+ * The inspection keys on the parent-child edge, so routing the spawn through
9
+ * a one-level intermediary escapes it.
10
+ *
11
+ * The intermediary is powershell.exe driven by -EncodedCommand: the script
12
+ * rides as one base64 token (no quoting stress from the spawning side), and
13
+ * the script splats a single-quoted array so argv reaches the tool
14
+ * byte-identical — the only escape in a PS single-quoted literal is doubling
15
+ * inner single quotes; double quotes, backslashes, and cmd metacharacters
16
+ * are all literal (verified E2E: the pinned wake command's load-bearing
17
+ * quotes survive, door READY ~1.4s). powershell.exe ships with every
18
+ * Windows; no extra prerequisite is imposed.
19
+ *
20
+ * Exit-code contract: the script exits with the tool's own exit status. With
21
+ * the not-found guard requested, a tool that does not resolve on PATH exits
22
+ * TOOL_NOT_FOUND_EXIT before any invocation — distinct from every real tool
23
+ * exit, so callers can keep treating "tool missing" as a degradation instead
24
+ * of a fault.
25
+ */
26
+
27
+ /** Distinct not-found sentinel: no real tool invocation ran when this is the
28
+ * exit status (ripgrep itself only ever exits 0/1/2; ssh diagnostics arrive
29
+ * via stderr instead). */
30
+ export const TOOL_NOT_FOUND_EXIT = 127;
31
+
32
+ /** PS single-quote literal: the only escape is doubling inner single quotes. */
33
+ function psSingleQuoted(arg: string): string {
34
+ return `'${arg.replaceAll("'", "''")}'`;
35
+ }
36
+
37
+ /** The relay script. `notFoundGuard` adds a Get-Command pre-check that exits
38
+ * TOOL_NOT_FOUND_EXIT when argv[0] does not resolve — for callers that must
39
+ * distinguish a missing tool from a tool's own failure exit. */
40
+ export function toolRelayScript(argv: readonly string[], options: { notFoundGuard?: boolean } = {}): string {
41
+ const guard = options.notFoundGuard === true
42
+ ? " if (-not (Get-Command -LiteralName $a[0] -ErrorAction SilentlyContinue)) { exit 127 };"
43
+ : "";
44
+ return `$a = @(${argv.map(psSingleQuoted).join(",")});${guard} & $a[0] $a[1..($a.Count-1)]; exit $LASTEXITCODE`;
45
+ }
46
+
47
+ /** The powershell carrier argv for a tool invocation: spawn THIS instead of
48
+ * the tool when the direct bun-to-tool edge is taxed (win32 real-tool
49
+ * spawns). Test-injected commands must not be wrapped. */
50
+ export function toolRelayArgv(argv: readonly string[], options: { notFoundGuard?: boolean } = {}): string[] {
51
+ return [
52
+ "powershell",
53
+ "-NoProfile",
54
+ "-EncodedCommand",
55
+ Buffer.from(toolRelayScript(argv, options), "utf16le").toString("base64"),
56
+ ];
57
+ }