ashlr-plugin 1.36.4 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +49 -8
  5. package/commands/ashlr-doctor.md +29 -0
  6. package/commands/ashlr-status.md +1 -1
  7. package/docs/PHANTOM-USAGE.md +58 -0
  8. package/docs/architecture.md +5 -5
  9. package/docs/ashlr-handshake.md +36 -0
  10. package/docs/assets/og.svg +1 -1
  11. package/docs/benchmarks-v2.json +226 -177
  12. package/docs/benchmarks.md +20 -10
  13. package/docs/competitive-wozcode.md +2 -2
  14. package/docs/embeddings-gateway.md +39 -0
  15. package/docs/index.html +3 -3
  16. package/docs/legal.md +1 -1
  17. package/docs/llm-providers.md +15 -1
  18. package/docs/multi-host-mcp.md +25 -3
  19. package/docs/phantom-preflight.md +23 -0
  20. package/docs/team-genome.md +2 -1
  21. package/hooks/_hook-timing-ledger.ts +2 -9
  22. package/hooks/hooks.json +3 -106
  23. package/hooks/posttooluse-stale-result.ts +3 -0
  24. package/hooks/pretooluse-common.ts +1 -1
  25. package/hooks/pretooluse-notebookedit.ts +1 -1
  26. package/hooks/pretooluse-write.ts +1 -1
  27. package/hooks/session-start.ts +48 -26
  28. package/package.json +9 -4
  29. package/ports/cursor/mcp.json +1 -1
  30. package/scripts/_read-thresholds.ts +119 -0
  31. package/scripts/ashlr-mcp.mjs +15 -0
  32. package/scripts/ashlr-mcp.ts +3 -0
  33. package/scripts/ashlr-plugin.mjs +59 -0
  34. package/scripts/bench-read-real.ts +90 -0
  35. package/scripts/bootstrap.mjs +14 -4
  36. package/scripts/cli.ts +35 -10
  37. package/scripts/context-status.ts +7 -2
  38. package/scripts/deploy-smoke.sh +8 -4
  39. package/scripts/doctor.ts +171 -42
  40. package/scripts/genome-auto-consolidate.ts +4 -2
  41. package/scripts/genome-init.ts +3 -3
  42. package/scripts/genome-key.ts +64 -7
  43. package/scripts/handshake.mjs +51 -0
  44. package/scripts/hook-bootstrap.mjs +170 -17
  45. package/scripts/mcp-entrypoint.ts +3 -0
  46. package/scripts/ollama-setup.ts +5 -4
  47. package/scripts/run-benchmark.ts +28 -18
  48. package/scripts/upgrade-flow.ts +9 -14
  49. package/servers/_base-urls.ts +48 -0
  50. package/servers/_companions.ts +258 -0
  51. package/servers/_cwd-clamp.ts +38 -1
  52. package/servers/_edit-match.ts +12 -2
  53. package/servers/_embedding-model.ts +77 -0
  54. package/servers/_gateway-target.ts +179 -0
  55. package/servers/_llm-providers/anthropic.ts +10 -4
  56. package/servers/_llm-providers/cloud.ts +9 -7
  57. package/servers/_llm-providers/index.ts +8 -6
  58. package/servers/_llm-providers/local.ts +9 -5
  59. package/servers/_mcp-host.ts +6 -0
  60. package/servers/_miss-tracker.ts +121 -0
  61. package/servers/_phantom-exec.ts +135 -0
  62. package/servers/_phantom-preflight.ts +130 -0
  63. package/servers/_pid-alive.ts +40 -0
  64. package/servers/_pro-token-keychain.ts +149 -0
  65. package/servers/_redact-keys.ts +75 -0
  66. package/servers/_rg-text.ts +111 -0
  67. package/servers/_router.ts +20 -2
  68. package/servers/_run-with-timeout.ts +56 -12
  69. package/servers/_test-watch.ts +1 -10
  70. package/servers/_token-measure.ts +3 -1
  71. package/servers/_tool-base.ts +6 -0
  72. package/servers/bash-server.ts +1 -10
  73. package/servers/edit-server.ts +24 -1
  74. package/servers/efficiency-server.ts +12 -2
  75. package/servers/genome-server-handlers.ts +2 -2
  76. package/servers/genome-server.ts +17 -17
  77. package/servers/grep-server.ts +6 -2
  78. package/servers/orient-server-handlers.ts +3 -2
  79. package/servers/orient-server.ts +16 -9
  80. package/servers/read-server.ts +1 -1
  81. package/servers/write-server-handlers.ts +10 -2
@@ -0,0 +1,149 @@
1
+ /**
2
+ * OS keychain storage for ASHLR_PRO_TOKEN, with the old plaintext
3
+ * ~/.ashlr/env line as the fallback.
4
+ *
5
+ * macOS: `security -i` (login keychain, token on stdin). Linux: `secret-tool` (libsecret), which
6
+ * reads the secret from stdin. Anything else, or a backend that fails, falls
7
+ * back to the 0600 `export ASHLR_PRO_TOKEN=` line in ~/.ashlr/env.
8
+ * ASHLR_KEYCHAIN=0 turns the keychain off entirely.
9
+ *
10
+ * Token values are never logged or put in error messages.
11
+ */
12
+
13
+ import { spawnSync } from "child_process";
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
15
+ import { dirname } from "path";
16
+
17
+ export const KEYCHAIN_SERVICE = "ashlr-plugin";
18
+ export const KEYCHAIN_ACCOUNT = "ASHLR_PRO_TOKEN";
19
+ const ENV_LINE_PREFIX = "export ASHLR_PRO_TOKEN=";
20
+ /** The one-line warning shown when the token had to go to the plaintext file. */
21
+ export const PLAINTEXT_FALLBACK_WARNING =
22
+ "No usable OS keychain; ASHLR_PRO_TOKEN saved in plain text to ~/.ashlr/env (mode 0600).";
23
+ const SAFE_TOKEN = /^[A-Za-z0-9._~+\/=:-]+$/;
24
+
25
+ export interface RunResult {
26
+ status: number | null;
27
+ stdout: string;
28
+ }
29
+ export type Runner = (cmd: string, args: string[], input?: string) => RunResult;
30
+
31
+ export interface KeychainDeps {
32
+ platform?: NodeJS.Platform;
33
+ env?: NodeJS.ProcessEnv;
34
+ run?: Runner;
35
+ }
36
+
37
+ const defaultRunner: Runner = (cmd, args, input) => {
38
+ try {
39
+ const r = spawnSync(cmd, args, {
40
+ input,
41
+ encoding: "utf8",
42
+ timeout: 5000,
43
+ stdio: ["pipe", "pipe", "ignore"],
44
+ });
45
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
46
+ } catch {
47
+ return { status: null, stdout: "" };
48
+ }
49
+ };
50
+
51
+ function backend(deps: KeychainDeps): "darwin" | "linux" | null {
52
+ const env = deps.env ?? process.env;
53
+ if (env["ASHLR_KEYCHAIN"] === "0") return null;
54
+ const platform = deps.platform ?? process.platform;
55
+ if (platform === "darwin") return "darwin";
56
+ if (platform === "linux") return "linux";
57
+ return null;
58
+ }
59
+
60
+ /** Store the token in the OS keychain. True only when the backend succeeded. */
61
+ export function keychainStore(token: string, deps: KeychainDeps = {}): boolean {
62
+ const run = deps.run ?? defaultRunner;
63
+ const b = backend(deps);
64
+ if (!b || !token) return false;
65
+ if (b === "darwin") {
66
+ // `security -i` reads the command from stdin, so the token never sits on
67
+ // argv (visible to `ps`). Its parser splits on whitespace and quotes, so
68
+ // only tokens of plain token characters go this way; anything else is
69
+ // left to the file fallback rather than risk a mangled value.
70
+ if (!SAFE_TOKEN.test(token)) return false;
71
+ return run(
72
+ "security",
73
+ ["-i"],
74
+ `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${KEYCHAIN_ACCOUNT} -w ${token}\n`,
75
+ ).status === 0;
76
+ }
77
+ return run(
78
+ "secret-tool",
79
+ ["store", "--label=ashlr-plugin pro token", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT],
80
+ token,
81
+ ).status === 0;
82
+ }
83
+
84
+ /** Read the token from the OS keychain, or null. */
85
+ export function keychainLoad(deps: KeychainDeps = {}): string | null {
86
+ const run = deps.run ?? defaultRunner;
87
+ const b = backend(deps);
88
+ if (!b) return null;
89
+ const r = b === "darwin"
90
+ ? run("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"])
91
+ : run("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT]);
92
+ if (r.status !== 0) return null;
93
+ const t = r.stdout.trim();
94
+ return t || null;
95
+ }
96
+
97
+ function readEnv(envFile: string): string {
98
+ try { return readFileSync(envFile, "utf8"); } catch { return ""; }
99
+ }
100
+
101
+ function writeEnv(envFile: string, lines: string[]): void {
102
+ mkdirSync(dirname(envFile), { recursive: true, mode: 0o700 });
103
+ const body = lines.join("\n").trim();
104
+ writeFileSync(envFile, body ? body + "\n" : "", { encoding: "utf8", mode: 0o600 });
105
+ try { chmodSync(envFile, 0o600); } catch { /* best-effort */ }
106
+ }
107
+
108
+ /** The token on the env file's `export ASHLR_PRO_TOKEN=` line, or null. */
109
+ export function envFileToken(envFile: string): string | null {
110
+ for (const line of readEnv(envFile).split("\n")) {
111
+ if (!line.startsWith(ENV_LINE_PREFIX)) continue;
112
+ let v = line.slice(ENV_LINE_PREFIX.length).trim();
113
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
114
+ return v || null;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function withoutTokenLine(envFile: string): string[] {
120
+ return readEnv(envFile).split("\n").filter((l) => !l.startsWith(ENV_LINE_PREFIX));
121
+ }
122
+
123
+ /**
124
+ * Save the token: keychain first (and drop any plaintext line), otherwise the
125
+ * plaintext env line. Returns where it went.
126
+ */
127
+ export function storeProToken(token: string, envFile: string, deps: KeychainDeps = {}): "keychain" | "file" {
128
+ if (keychainStore(token, deps) && keychainLoad(deps) === token) {
129
+ if (existsSync(envFile)) writeEnv(envFile, withoutTokenLine(envFile));
130
+ return "keychain";
131
+ }
132
+ const lines = withoutTokenLine(envFile);
133
+ lines.push(`${ENV_LINE_PREFIX}${token}`);
134
+ writeEnv(envFile, lines);
135
+ return "file";
136
+ }
137
+
138
+ /**
139
+ * One-time migration: move a plaintext env-file token into the keychain. The
140
+ * line is removed only after the keychain reads the same value back, so a
141
+ * failed or partial store never loses the token.
142
+ */
143
+ export function migrateEnvFileToken(envFile: string, deps: KeychainDeps = {}): "migrated" | "none" | "kept" {
144
+ const token = envFileToken(envFile);
145
+ if (!token) return "none";
146
+ if (!keychainStore(token, deps) || keychainLoad(deps) !== token) return "kept";
147
+ writeEnv(envFile, withoutTokenLine(envFile));
148
+ return "migrated";
149
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Strip key material from text that came back from a model endpoint before it
3
+ * reaches an error message, a log line or stderr.
4
+ *
5
+ * A gateway that rejects a credential may quote it back in its error body,
6
+ * whole or in part. Any run of 8 or more characters that also appears inside
7
+ * a configured key is replaced with "[redacted]".
8
+ */
9
+
10
+ /** Environment variables that can hold key material for a model endpoint. */
11
+ export const KEY_ENV_VARS = [
12
+ "ASHLR_LLM_KEY",
13
+ "ANTHROPIC_API_KEY",
14
+ "OPENAI_API_KEY",
15
+ "ASHLR_EMBED_KEY",
16
+ "ASHLR_PRO_TOKEN",
17
+ "PHANTOM_PROXY_TOKEN",
18
+ ] as const;
19
+
20
+ const WINDOW = 8;
21
+
22
+ /**
23
+ * Redact every run of `WINDOW` or more characters of `text` that also occurs
24
+ * inside one of the configured key values or one of `extra`.
25
+ */
26
+ export function redactKnownKeys(
27
+ text: string,
28
+ extra: Array<string | null | undefined> = [],
29
+ env: NodeJS.ProcessEnv = process.env,
30
+ ): string {
31
+ const secrets = [...KEY_ENV_VARS.map((v) => env[v]), ...extra].filter(
32
+ (s): s is string => typeof s === "string" && s.trim().length >= WINDOW,
33
+ );
34
+ if (secrets.length === 0 || text.length < WINDOW) return text;
35
+
36
+ const windows = new Set<string>();
37
+ for (const s of secrets) {
38
+ for (let i = 0; i + WINDOW <= s.length; i++)
39
+ windows.add(s.slice(i, i + WINDOW));
40
+ }
41
+
42
+ const covered = new Uint8Array(text.length);
43
+ let any = false;
44
+ for (let i = 0; i + WINDOW <= text.length; i++) {
45
+ if (windows.has(text.slice(i, i + WINDOW))) {
46
+ covered.fill(1, i, i + WINDOW);
47
+ any = true;
48
+ }
49
+ }
50
+ if (!any) return text;
51
+
52
+ let out = "";
53
+ for (let i = 0; i < text.length; ) {
54
+ if (covered[i]) {
55
+ while (i < text.length && covered[i]) i++;
56
+ out += "[redacted]";
57
+ } else {
58
+ out += text[i++];
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Read a failed response's body for an error message: bounded, redacted, then
66
+ * cut to `max` characters.
67
+ */
68
+ export async function redactedBody(
69
+ res: Response,
70
+ extra: Array<string | null | undefined> = [],
71
+ max = 200,
72
+ ): Promise<string> {
73
+ const raw = await res.text().catch(() => "");
74
+ return redactKnownKeys(raw.slice(0, 4096), extra).slice(0, max);
75
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * _rg-text.ts — the ripgrep fallback for ashlr__grep, as plain text.
3
+ *
4
+ * The fallback used to run `rg --json` and cut the JSON stream to its first
5
+ * 2000 and last 1000 characters. JSON event lines are roughly three times the
6
+ * size of `path:line:text`, so the cut kept only a handful of matches (often
7
+ * as a half-open JSON object), and savings were counted against the JSON size
8
+ * rather than what a plain grep would have returned.
9
+ *
10
+ * Here ripgrep prints `path:line:text`; if that exceeds the budget, whole
11
+ * lines are kept from the head and tail and the omitted count is stated.
12
+ */
13
+
14
+ /** ripgrep arguments for compact `path:line:text` output. */
15
+ export function rgTextArgs(pattern: string, searchRoot: string): string[] {
16
+ return [
17
+ "-n",
18
+ "--no-heading",
19
+ "--color",
20
+ "never",
21
+ // Minified bundles and lockfiles can put megabytes on one line.
22
+ "--max-columns",
23
+ "300",
24
+ "--max-columns-preview",
25
+ "--",
26
+ pattern,
27
+ searchRoot,
28
+ ];
29
+ }
30
+
31
+ export const RG_TEXT_BUDGET = 4000;
32
+ const HEAD_BUDGET = 2600;
33
+ const TAIL_BUDGET = 1000;
34
+
35
+ /**
36
+ * Why ripgrep stopped before finishing, or null when it ran to completion.
37
+ *
38
+ * A killed search still leaves its partial stdout behind: spawnSync keeps what
39
+ * it buffered on ENOBUFS and on its timeout, and runWithTimeout keeps the
40
+ * first 10 MB. Counting that as the whole result made the header state a
41
+ * total that was only how far ripgrep got, and the last line was usually cut
42
+ * mid-path.
43
+ */
44
+ export function rgStopReason(res: {
45
+ error?: unknown;
46
+ signal?: string | null;
47
+ timedOut?: boolean;
48
+ stderr?: string | null;
49
+ }): string | null {
50
+ const code = (res.error as { code?: unknown } | undefined)?.code;
51
+ if (res.timedOut || code === "ETIMEDOUT") return "it timed out";
52
+ if (code === "ENOBUFS" || /stdout capped at/.test(res.stderr ?? "")) return "its output passed the size cap";
53
+ if (res.signal) return `it was killed (${res.signal})`;
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Keep `raw` whole when it fits the budget; otherwise keep whole lines from
59
+ * the head and tail and say how many matching lines were omitted.
60
+ *
61
+ * `stoppedEarly` (from `rgStopReason`) marks `raw` as a prefix of the real
62
+ * result: the cut-off last line is dropped and the counts are stated as lower
63
+ * bounds, even when what is left fits the budget.
64
+ */
65
+ export function truncateRgText(raw: string, stoppedEarly: string | null = null): string {
66
+ if (stoppedEarly) {
67
+ const complete = raw.endsWith("\n") ? raw : raw.slice(0, raw.lastIndexOf("\n") + 1);
68
+ const lines = complete.replace(/\n$/, "").split("\n").filter((l) => l.length > 0);
69
+ const note = `[ashlr__grep] ripgrep stopped early because ${stoppedEarly}; at least ${lines.length} matching lines in ${countFiles(lines)} files. Narrow the pattern or cwd.`;
70
+ if (complete.length <= RG_TEXT_BUDGET) return lines.length > 0 ? `${note}\n${lines.join("\n")}` : note;
71
+ return cut(lines, note);
72
+ }
73
+ if (raw.length <= RG_TEXT_BUDGET) return raw;
74
+ const lines = raw.replace(/\n$/, "").split("\n");
75
+ return cut(
76
+ lines,
77
+ `[ashlr__grep] ${lines.length} matching lines in ${countFiles(lines)} files; showing {shown}. Narrow the pattern or cwd to see the rest.`,
78
+ );
79
+ }
80
+
81
+ function countFiles(lines: readonly string[]): number {
82
+ const files = new Set<string>();
83
+ for (const line of lines) {
84
+ const m = /^(.*?):\d+:/.exec(line);
85
+ if (m) files.add(m[1]!);
86
+ }
87
+ return files.size;
88
+ }
89
+
90
+ function cut(lines: readonly string[], header: string): string {
91
+ const head: string[] = [];
92
+ let used = 0;
93
+ for (const line of lines) {
94
+ if (used + line.length + 1 > HEAD_BUDGET && head.length > 0) break;
95
+ head.push(line);
96
+ used += line.length + 1;
97
+ }
98
+ const tail: string[] = [];
99
+ used = 0;
100
+ for (let i = lines.length - 1; i >= head.length; i--) {
101
+ const line = lines[i]!;
102
+ if (used + line.length + 1 > TAIL_BUDGET) break;
103
+ tail.unshift(line);
104
+ used += line.length + 1;
105
+ }
106
+ const omitted = lines.length - head.length - tail.length;
107
+ const parts = [header.replace("{shown}", String(head.length + tail.length)), ...head];
108
+ if (omitted > 0) parts.push(`[... ${omitted} matching lines omitted ...]`);
109
+ parts.push(...tail);
110
+ return parts.join("\n");
111
+ }
@@ -24,7 +24,24 @@
24
24
 
25
25
  import { listTools, runStandalone } from "./_tool-base";
26
26
  import { detectMcpHost } from "./_mcp-host";
27
+ import { adoptLaunchCwd } from "./_cwd-clamp";
27
28
  import "./_router-handlers";
29
+ import { readFileSync } from "fs";
30
+ import { join } from "path";
31
+
32
+ /** Reported in MCP `serverInfo` and the startup banner; ASHLR_VERSION overrides. */
33
+ function pluginVersion(): string {
34
+ if (process.env.ASHLR_VERSION) return process.env.ASHLR_VERSION;
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as { version?: unknown };
37
+ if (typeof pkg.version === "string" && pkg.version) return pkg.version;
38
+ } catch {
39
+ // A partial copy of the plugin without package.json: report "dev".
40
+ }
41
+ return "dev";
42
+ }
43
+
44
+ const VERSION = pluginVersion();
28
45
 
29
46
  // ---------------------------------------------------------------------------
30
47
  // Entry point
@@ -38,13 +55,14 @@ async function main(): Promise<void> {
38
55
  process.exit(0);
39
56
  }
40
57
 
58
+ adoptLaunchCwd();
41
59
  const toolCount = listTools().length;
42
60
  const host = detectMcpHost();
43
61
  process.stderr.write(
44
- `[ashlr-router] starting · ${toolCount} tools registered · version=${process.env.ASHLR_VERSION ?? "dev"} · host=${host}\n`,
62
+ `[ashlr-router] starting · ${toolCount} tools registered · version=${VERSION} · host=${host}\n`,
45
63
  );
46
64
 
47
- await runStandalone("ashlr-router", process.env.ASHLR_VERSION ?? "dev");
65
+ await runStandalone("ashlr-router", VERSION);
48
66
  }
49
67
 
50
68
  if (import.meta.main) {
@@ -12,6 +12,12 @@
12
12
  * - SIGTERM first, 250 ms grace, then SIGKILL.
13
13
  * - Never throws on subprocess error — returns `exitCode: -1` with the error
14
14
  * surfaced in `stderr`.
15
+ * - Always settles. `close` is the normal path, but it waits for every stdio
16
+ * pipe to end and has been seen not to fire at all (ashlr-plugin main run
17
+ * 39195707592, macOS: a `echo` call outlived its own 5 s timeout and hit the
18
+ * 15 s test timeout). So `exit` starts a short drain window after which we
19
+ * resolve with what was collected, and a hard deadline slightly past the
20
+ * kill grace resolves a run that never reports `exit` or `close`.
15
21
  * - Stdout and stderr are each capped at 10 MB to prevent OOM on runaway
16
22
  * output; excess bytes are silently dropped (the cap notice appears in
17
23
  * stderr so callers can detect truncation).
@@ -19,6 +25,11 @@
19
25
 
20
26
  import { spawn } from "child_process";
21
27
 
28
+ /** After `exit`, how long to wait for `close` (pipes drained) before settling. */
29
+ const EXIT_DRAIN_MS = 1_000;
30
+ /** Past the timeout and the 250 ms SIGKILL grace, how long before we give up. */
31
+ const HARD_DEADLINE_SLACK_MS = 2_000;
32
+
22
33
  /** Maximum bytes buffered per stream before we stop accumulating. */
23
34
  const STREAM_CAP = 10 * 1024 * 1024; // 10 MB
24
35
 
@@ -30,6 +41,13 @@ export interface RunWithTimeoutOptions {
30
41
  timeoutMs: number;
31
42
  /** Optional stdin to feed the child process (UTF-8 string). */
32
43
  input?: string;
44
+ /**
45
+ * Optional abort signal. Aborting kills the process group exactly like a
46
+ * timeout does (and reports `timedOut: true`). Callers that stop waiting
47
+ * early, such as a total cap racing several probes, use this so the child
48
+ * and its descendants do not outlive the caller.
49
+ */
50
+ signal?: AbortSignal;
33
51
  }
34
52
 
35
53
  export interface RunWithTimeoutResult {
@@ -115,7 +133,10 @@ export function runWithTimeout(opts: RunWithTimeoutOptions): Promise<RunWithTime
115
133
  }
116
134
 
117
135
  /** Kill the process tree and set timedOut flag. */
136
+ let killed = false;
118
137
  function killTree(): void {
138
+ if (killed) return;
139
+ killed = true;
119
140
  timedOut = true;
120
141
  if (isWin) {
121
142
  // taskkill /T cascades through child processes on Windows.
@@ -148,26 +169,49 @@ export function runWithTimeout(opts: RunWithTimeoutOptions): Promise<RunWithTime
148
169
  }
149
170
  }
150
171
 
172
+ let settled = false;
173
+ let drainTimer: ReturnType<typeof setTimeout> | undefined;
174
+ let exitCode: number | null = null;
175
+
151
176
  const timer = setTimeout(killTree, opts.timeoutMs);
177
+ const onAbort = (): void => killTree();
178
+ if (opts.signal) {
179
+ if (opts.signal.aborted) killTree();
180
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
181
+ }
182
+ const deadline = setTimeout(
183
+ () => settle(exitCode, "hard deadline"),
184
+ opts.timeoutMs + 250 + HARD_DEADLINE_SLACK_MS,
185
+ );
152
186
 
153
- child.on("close", (code) => {
187
+ /** Resolve exactly once and clear every timer. */
188
+ function settle(code: number | null, note?: string, spawnErr?: Error): void {
189
+ if (settled) return;
190
+ settled = true;
154
191
  clearTimeout(timer);
192
+ clearTimeout(deadline);
193
+ opts.signal?.removeEventListener("abort", onAbort);
194
+ if (drainTimer) clearTimeout(drainTimer);
195
+ let err = stderr;
196
+ if (spawnErr) err += `\n[spawn error: ${spawnErr.message}]`;
197
+ else if (note && code === null) err += `\n[_run-with-timeout: settled by ${note}]`;
155
198
  resolveP({
156
199
  stdout,
157
- stderr,
158
- exitCode: code ?? (timedOut ? -1 : -1),
200
+ stderr: err,
201
+ exitCode: spawnErr ? -1 : (code ?? -1),
159
202
  timedOut,
160
203
  });
161
- });
204
+ }
162
205
 
163
- child.on("error", (err) => {
164
- clearTimeout(timer);
165
- resolveP({
166
- stdout,
167
- stderr: stderr + `\n[spawn error: ${err.message}]`,
168
- exitCode: -1,
169
- timedOut,
170
- });
206
+ child.on("exit", (code) => {
207
+ exitCode = code;
208
+ // `close` normally follows within milliseconds. If it does not (a pipe
209
+ // held open, or a lost event), settle with what we have.
210
+ drainTimer = setTimeout(() => settle(code, "exit drain"), EXIT_DRAIN_MS);
171
211
  });
212
+
213
+ child.on("close", (code) => settle(code ?? exitCode));
214
+
215
+ child.on("error", (err) => settle(null, undefined, err));
172
216
  });
173
217
  }
@@ -34,6 +34,7 @@ import { homedir } from "os";
34
34
  import { join, sep } from "path";
35
35
  import { randomBytes } from "crypto";
36
36
  import { registerExternalSessions, type ExternalSession } from "./bash-server";
37
+ import { pidAlive } from "./_pid-alive";
37
38
 
38
39
  // ---------------------------------------------------------------------------
39
40
  // Constants
@@ -180,16 +181,6 @@ export async function reloadWatchSessions(): Promise<void> {
180
181
  }
181
182
  }
182
183
 
183
- function pidAlive(pid: number): boolean {
184
- if (!pid || pid <= 0) return false;
185
- try {
186
- process.kill(pid, 0);
187
- return true;
188
- } catch {
189
- return false;
190
- }
191
- }
192
-
193
184
  // ---------------------------------------------------------------------------
194
185
  // File watching
195
186
  // ---------------------------------------------------------------------------
@@ -16,7 +16,9 @@ import { createHash } from "crypto";
16
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
17
17
  import { homedir } from "os";
18
18
  import { join } from "path";
19
+ import { anthropicBaseUrl } from "./_base-urls.ts";
19
20
  import { estimateTokensFromString } from "@ashlr/core-efficiency";
21
+ import { safeKeyedFetch } from "./_gateway-target.ts";
20
22
 
21
23
  export type MeasurementMode = "estimate" | "measured";
22
24
 
@@ -126,7 +128,7 @@ export async function measureTokens(text: string): Promise<number | null> {
126
128
  }
127
129
 
128
130
  try {
129
- const res = await fetch("https://api.anthropic.com/v1/messages", {
131
+ const res = await safeKeyedFetch(`${anthropicBaseUrl()}/v1/messages`, {
130
132
  method: "POST",
131
133
  signal: AbortSignal.timeout(5_000),
132
134
  headers: {
@@ -27,6 +27,7 @@ import { writeCrashDump } from "./_crash-dump";
27
27
  import { openContextDb, type ContextDb } from "./_embedding-cache";
28
28
  import { recordTelemetryEvent, isTelemetryEnabled } from "./_telemetry";
29
29
  import { detectMcpHost, type McpHost } from "./_mcp-host";
30
+ import { isPathRefusal } from "./_cwd-clamp";
30
31
 
31
32
  /** Protocol-native guidance applies to every MCP host without host-specific hooks. */
32
33
  export const MCP_EFFICIENCY_INSTRUCTIONS = [
@@ -218,6 +219,11 @@ export async function runStandalone(
218
219
  });
219
220
  }
220
221
 
222
+ // Path refusals come back from many handlers as plain text; flag them so
223
+ // hosts and agents see a failed call, not a successful read of a message.
224
+ const first = (result as { content?: Array<{ text?: unknown }> })?.content?.[0];
225
+ if (first && isPathRefusal(first.text)) (result as { isError?: boolean }).isError = true;
226
+
221
227
  return result as { content: unknown[] };
222
228
  } catch (err) {
223
229
  // Per-handler crash isolation: one handler's throw must not take the
@@ -60,6 +60,7 @@ import { summarizeIfLarge, PROMPTS, confidenceBadge, confidenceTier } from "./_s
60
60
  import { recordSaving as recordSavingCore } from "./_stats";
61
61
  import { logEvent } from "./_events";
62
62
  import { clampToCwd } from "./_cwd-clamp";
63
+ import { pidAlive } from "./_pid-alive";
63
64
 
64
65
  // Bash always records under the "ashlr__bash" tool bucket.
65
66
  async function recordSaving(rawBytes: number, compactBytes: number): Promise<number> {
@@ -587,16 +588,6 @@ interface PersistedSession {
587
588
  cumulativeBytes: number;
588
589
  }
589
590
 
590
- function pidAlive(pid: number): boolean {
591
- if (!pid || pid <= 0) return false;
592
- try {
593
- process.kill(pid, 0);
594
- return true;
595
- } catch {
596
- return false;
597
- }
598
- }
599
-
600
591
  async function persistSessions(): Promise<void> {
601
592
  await mkdir(dirname(SESSIONS_PATH), { recursive: true });
602
593
  const payload: PersistedSession[] = [];
@@ -37,14 +37,27 @@ export interface EditResult {
37
37
 
38
38
  const FUZZY_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB
39
39
 
40
+ const TINY_EDIT_CHARS = 120;
41
+
40
42
  function summarizeEdit(
41
43
  relPath: string,
42
44
  search: string,
43
45
  replace: string,
44
46
  matchCount: number,
45
47
  strict: boolean,
48
+ /** Echo the first line of what was removed and added. Only worth its tokens when the match was not exact. */
49
+ echo = false,
46
50
  ): string {
47
51
  const first = (s: string) => s.split("\n")[0]?.slice(0, 72) ?? "";
52
+ // Tiny edits: the removed/added detail lines cost more than the edit itself,
53
+ // and the caller already knows what it sent. Return the header only.
54
+ // Same for an exact, unique match: the hunk applied exactly as sent, so the
55
+ // echo is the caller's own text played back. Fuzzy matches and replace-all
56
+ // edits keep the detail lines.
57
+ // `echo` (fuzzy) always keeps them: the caller cannot know what was replaced.
58
+ if (!echo && (search.length + replace.length < TINY_EDIT_CHARS || (strict && matchCount === 1))) {
59
+ return `[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`;
60
+ }
48
61
  return [
49
62
  `[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`,
50
63
  ` - removed (${estimateTokensFromString(search)} tok): ${first(search)}${search.length > 72 ? "…" : ""}`,
@@ -105,7 +118,9 @@ export async function ashlrEdit(input: EditArgs): Promise<EditResult> {
105
118
  refreshGenomeAfterEdit(abs, original, updated).catch(() => {});
106
119
 
107
120
  const baseBytes = search.length + replace.length;
108
- const compactSummary = summarizeEdit(relPath, search, replace, 1, true);
121
+ // Echo the file text the fuzzy match actually replaced, not the caller's search.
122
+ const matched = original.slice(fuzzyMatch.start, fuzzyMatch.end);
123
+ const compactSummary = summarizeEdit(relPath, matched, replace, 1, true, true);
109
124
  await recordSaving(baseBytes, compactSummary.length, "ashlr__edit");
110
125
 
111
126
  appendEdit({ relPath, hunksApplied: 1 });
@@ -135,6 +150,14 @@ export async function ashlrEdit(input: EditArgs): Promise<EditResult> {
135
150
  throw new Error(`ashlr__edit: search string not found in ${relPath}`);
136
151
  }
137
152
 
153
+ // Strict uniqueness must count overlapping occurrences too: "}\n}" occurs
154
+ // twice in "}\n}\n}" even though a non-overlapping scan finds it once.
155
+ if (strict && count === 1 && original.indexOf(search, original.indexOf(search) + 1) !== -1) {
156
+ let overlapping = 0;
157
+ for (let i = original.indexOf(search); i !== -1; i = original.indexOf(search, i + 1)) overlapping++;
158
+ count = overlapping;
159
+ }
160
+
138
161
  if (strict && count > 1) {
139
162
  throw new Error(
140
163
  `ashlr__edit: search string matched ${count} times in ${relPath}; pass strict:false to replace all, or widen the context to a unique span.`,
@@ -46,6 +46,7 @@ import {
46
46
  type SessionBucket,
47
47
  } from "./_stats";
48
48
  import { clampToCwd } from "./_cwd-clamp";
49
+ import { rgStopReason, rgTextArgs, truncateRgText } from "./_rg-text";
49
50
  import { getEmbeddingCache } from "./_tool-base";
50
51
  import { embed, upsertCorpus } from "./_embedding-model";
51
52
  import { populateGenomeEmbeddings } from "./_genome-embed-populator";
@@ -809,11 +810,13 @@ export async function ashlrGrep(input: { pattern: string; cwd?: string; bypassSu
809
810
 
810
811
  const res = await runWithTimeout({
811
812
  command: rgBin,
812
- args: ["--json", "-n", input.pattern, cwd],
813
+ args: rgTextArgs(input.pattern, cwd),
813
814
  timeoutMs: 15_000,
814
815
  });
816
+ // `raw` is what a plain grep would have returned, so savings are counted
817
+ // against that, not against a verbose wire format.
815
818
  const raw = res.stdout ?? "";
816
- const truncated = raw.length > 4000 ? raw.slice(0, 2000) + "\n\n[... truncated ...]\n\n" + raw.slice(-1000) : raw;
819
+ const truncated = truncateRgText(raw, rgStopReason(res));
817
820
  const summarized = await summarizeIfLarge(truncated, {
818
821
  toolName: "ashlr__grep",
819
822
  systemPrompt: PROMPTS.grep,
@@ -1006,6 +1009,8 @@ export async function ashlrEdit(input: EditArgs): Promise<EditResult> {
1006
1009
  return { text: compactSummary, hunksApplied };
1007
1010
  }
1008
1011
 
1012
+ const TINY_EDIT_CHARS = 120;
1013
+
1009
1014
  function summarizeEdit(
1010
1015
  relPath: string,
1011
1016
  search: string,
@@ -1014,6 +1019,11 @@ function summarizeEdit(
1014
1019
  strict: boolean,
1015
1020
  ): string {
1016
1021
  const first = (s: string) => s.split("\n")[0]?.slice(0, 72) ?? "";
1022
+ // Tiny edits: the removed/added detail lines cost more than the edit itself,
1023
+ // and the caller already knows what it sent. Return the header only.
1024
+ if (search.length + replace.length < TINY_EDIT_CHARS) {
1025
+ return `[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`;
1026
+ }
1017
1027
  return [
1018
1028
  `[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`,
1019
1029
  ` - removed (${estimateTokensFromString(search)} tok): ${first(search)}${search.length > 72 ? "…" : ""}`,