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
@@ -20,6 +20,8 @@ import { readFile, stat } from "fs/promises";
20
20
  import { homedir } from "os";
21
21
  import { join } from "path";
22
22
  import type { LlmProvider, LlmSummarizeResult } from "./types.ts";
23
+ import { safeKeyedFetch } from "../_gateway-target.ts";
24
+ import { redactedBody } from "../_redact-keys.ts";
23
25
 
24
26
  // ---------------------------------------------------------------------------
25
27
  // Constants
@@ -132,7 +134,7 @@ export const cloudProvider: LlmProvider = {
132
134
 
133
135
  let res: Response;
134
136
  try {
135
- res = await fetch(url, {
137
+ res = await safeKeyedFetch(url, {
136
138
  method: "POST",
137
139
  signal: ctl.signal,
138
140
  headers: {
@@ -152,11 +154,11 @@ export const cloudProvider: LlmProvider = {
152
154
 
153
155
  // 429 — rate limit (per-minute bucket) OR daily cap exhausted
154
156
  if (res.status === 429) {
155
- const body = await res.text().catch(() => "");
157
+ const body = await redactedBody(res, [token]);
156
158
  const retryHeader = res.headers.get("retry-after");
157
159
  const retryAfterSecs = retryHeader ? parseInt(retryHeader, 10) : undefined;
158
160
  throw new CloudRateLimitError(
159
- `Cloud LLM rate limit: ${body.slice(0, 200)}`,
161
+ `Cloud LLM rate limit: ${body}`,
160
162
  429,
161
163
  Number.isFinite(retryAfterSecs) ? retryAfterSecs : undefined,
162
164
  );
@@ -164,13 +166,13 @@ export const cloudProvider: LlmProvider = {
164
166
 
165
167
  // 5xx — backend error; let dispatcher fall through to onnx/local/snipCompact
166
168
  if (res.status >= 500) {
167
- const body = await res.text().catch(() => "");
168
- throw new Error(`Cloud LLM error ${res.status}: ${body.slice(0, 200)}`);
169
+ const body = await redactedBody(res, [token]);
170
+ throw new Error(`Cloud LLM error ${res.status}: ${body}`);
169
171
  }
170
172
 
171
173
  if (!res.ok) {
172
- const body = await res.text().catch(() => "");
173
- throw new Error(`Cloud LLM error ${res.status}: ${body.slice(0, 200)}`);
174
+ const body = await redactedBody(res, [token]);
175
+ throw new Error(`Cloud LLM error ${res.status}: ${body}`);
174
176
  }
175
177
 
176
178
  const latencyMs = Date.now() - t0;
@@ -34,9 +34,10 @@ import { bumpSummarization } from "../_stats.ts";
34
34
  import { costForLLM } from "../_pricing.ts";
35
35
  import { anthropicProvider, _resetAnthropicAvailabilityCache } from "./anthropic.ts";
36
36
  import { onnxProvider } from "./onnx.ts";
37
- import { localProvider, _resetLocalAvailabilityCache } from "./local.ts";
37
+ import { DEFAULT_LOCAL_LLM_URL, localProvider, _resetLocalAvailabilityCache } from "./local.ts";
38
38
  import { cloudProvider, _resetCloudAvailabilityCache } from "./cloud.ts";
39
39
  import type { LlmProvider, ProviderName } from "./types.ts";
40
+ import { safeKeyedFetch, safeTargetLabel } from "../_gateway-target.ts";
40
41
 
41
42
  export type { LlmProvider, ProviderName };
42
43
  export { _resetAnthropicAvailabilityCache, _resetLocalAvailabilityCache, _resetCloudAvailabilityCache };
@@ -277,15 +278,16 @@ export async function summarizeIfLarge(
277
278
  if (summary == null) {
278
279
  await logEvent("tool_fallback", { tool: opts.toolName, reason: "llm-unreachable" });
279
280
  // Per v1.22 review: when the SELECTED provider is local AND it failed,
280
- // surface a one-line stderr warning that names the URL the user
281
- // configured. Without this, a typo in ASHLR_LLM_URL silently degrades
281
+ // surface a one-line stderr warning that names the endpoint the user
282
+ // configured (scheme://host:port only, so credentials, path and query
283
+ // in ASHLR_LLM_URL never reach stderr). Without this, a typo in ASHLR_LLM_URL silently degrades
282
284
  // every summarization to truncation with the only signal buried inside
283
285
  // tool output. Only logged once per process to avoid spam.
284
286
  if (provider.name === "local" && !_localUnreachableWarned) {
285
287
  _localUnreachableWarned = true;
286
- const url = process.env.ASHLR_LLM_URL ?? "http://localhost:1234/v1";
288
+ const url = process.env.ASHLR_LLM_URL ?? DEFAULT_LOCAL_LLM_URL;
287
289
  process.stderr.write(
288
- `[ashlr] WARN: local LLM at ${url} unreachable; falling back to truncation. ` +
290
+ `[ashlr] WARN: local LLM at ${safeTargetLabel(url)} unreachable; falling back to truncation. ` +
289
291
  `Verify the endpoint, or unset ASHLR_LLM_URL to let auto-selection prefer Anthropic.\n`,
290
292
  );
291
293
  }
@@ -315,7 +317,7 @@ async function callLegacyEndpoint(rawText: string, opts: SummarizeOpts): Promise
315
317
  const ctl = new AbortController();
316
318
  const t = setTimeout(() => ctl.abort(), timeoutMs);
317
319
  try {
318
- const res = await fetch(`${url}/chat/completions`, {
320
+ const res = await safeKeyedFetch(`${url}/chat/completions`, {
319
321
  method: "POST",
320
322
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
321
323
  signal: ctl.signal,
@@ -2,7 +2,7 @@
2
2
  * Local LLM provider — LM Studio / Ollama via OpenAI-compat API.
3
3
  *
4
4
  * Preserves the existing _summarize.ts local-LLM logic verbatim:
5
- * - ASHLR_LLM_URL (default: http://localhost:1234/v1)
5
+ * - ASHLR_LLM_URL (default: http://127.0.0.1:1234/v1)
6
6
  * - ASHLR_LLM_KEY (default: "local-llm")
7
7
  * - ASHLR_LLM_MODEL (default: "qwen/qwen3-coder-30b@8bit")
8
8
  *
@@ -11,16 +11,20 @@
11
11
  */
12
12
 
13
13
  import type { LlmProvider, LlmSummarizeResult } from "./types.ts";
14
+ import { safeKeyedFetch } from "../_gateway-target.ts";
15
+
16
+ export const DEFAULT_LOCAL_LLM_URL = "http://127.0.0.1:1234/v1";
14
17
 
15
18
  function localEndpoint(): string {
16
19
  // Mirror the llmEndpoint() logic from the old _summarize.ts — pro-token
17
20
  // auto-routing uses the ashlr cloud; explicit ASHLR_LLM_URL wins; default
18
- // is LM Studio at localhost:1234.
21
+ // is LM Studio at 127.0.0.1:1234 (an IP literal: keyed calls follow the
22
+ // default-path gateway rules, which do not accept `localhost` by name).
19
23
  if (process.env.ASHLR_PRO_TOKEN && !process.env.ASHLR_LLM_URL) {
20
24
  const base = process.env.ASHLR_API_URL ?? "https://api.ashlr.ai";
21
25
  return `${base}/llm`;
22
26
  }
23
- return process.env.ASHLR_LLM_URL ?? "http://localhost:1234/v1";
27
+ return process.env.ASHLR_LLM_URL ?? DEFAULT_LOCAL_LLM_URL;
24
28
  }
25
29
 
26
30
  // Availability cache: { available, expiresAt }
@@ -46,7 +50,7 @@ export const localProvider: LlmProvider = {
46
50
  const t = setTimeout(() => ctl.abort(), 2_000);
47
51
 
48
52
  try {
49
- const res = await fetch(`${url}/models`, {
53
+ const res = await safeKeyedFetch(`${url}/models`, {
50
54
  signal: ctl.signal,
51
55
  headers: { Authorization: `Bearer ${process.env.ASHLR_LLM_KEY ?? "local-llm"}` },
52
56
  });
@@ -81,7 +85,7 @@ export const localProvider: LlmProvider = {
81
85
  const t0 = Date.now();
82
86
  let res: Response;
83
87
  try {
84
- res = await fetch(`${url}/chat/completions`, {
88
+ res = await safeKeyedFetch(`${url}/chat/completions`, {
85
89
  method: "POST",
86
90
  signal: ctl.signal,
87
91
  headers: {
@@ -21,6 +21,8 @@
21
21
  * Cline (cline_mcp_settings.json) → "cline"
22
22
  * Claude Desktop (claude_desktop_config.json) → "claude-desktop"
23
23
  * Codex CLI (~/.codex/config.toml) → "codex-cli"
24
+ * Gemini CLI (~/.gemini/settings.json) → "gemini-cli"
25
+ * Cursor (.cursor/mcp.json) → "cursor"
24
26
  * Anything else → "generic"
25
27
  *
26
28
  * Backwards compat: when unset, we infer "claude-code" so existing installs
@@ -33,6 +35,8 @@ export type McpHost =
33
35
  | "cline"
34
36
  | "claude-desktop"
35
37
  | "codex-cli"
38
+ | "gemini-cli"
39
+ | "cursor"
36
40
  | "generic";
37
41
 
38
42
  const KNOWN_HOSTS: ReadonlySet<McpHost> = new Set([
@@ -40,6 +44,8 @@ const KNOWN_HOSTS: ReadonlySet<McpHost> = new Set([
40
44
  "cline",
41
45
  "claude-desktop",
42
46
  "codex-cli",
47
+ "gemini-cli",
48
+ "cursor",
43
49
  "generic",
44
50
  ]);
45
51
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * _miss-tracker.ts: compression miss tracking (self-tuning P1).
3
+ *
4
+ * A "miss" is a compressed ashlr__read / ashlr__grep result for a target that
5
+ * is followed by a native Read / Grep of the same target within
6
+ * MISS_WINDOW_TURNS turns. That re-read signals the compact output was not
7
+ * enough. Counts feed the per-repo tuner (P2).
8
+ *
9
+ * Privacy: local only. Targets are kept only as an 8-char hash in the
10
+ * per-session pending file; the persistent stats file holds counts keyed by
11
+ * ashlr tool and file extension, never paths or content.
12
+ *
13
+ * Off switches: ASHLR_TUNING=off or ASHLR_SESSION_LOG=0. Best-effort, never throws.
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
17
+ import { extname, join, resolve } from "path";
18
+
19
+ import { sha8 } from "./_history-tracker";
20
+
21
+ /** A native re-read within this many turns of a compressed result counts as a miss. */
22
+ export const MISS_WINDOW_TURNS = 3;
23
+
24
+ const ASHLR_TOOLS: Record<string, "read" | "grep"> = {
25
+ ashlr__read: "read",
26
+ ashlr__grep: "grep",
27
+ "mcp__ashlr-efficiency__ashlr__read": "read",
28
+ "mcp__ashlr-efficiency__ashlr__grep": "grep",
29
+ mcp__plugin_ashlr_ashlr__ashlr__read: "read",
30
+ mcp__plugin_ashlr_ashlr__ashlr__grep: "grep",
31
+ };
32
+ const NATIVE_TOOLS: Record<string, "read" | "grep"> = { Read: "read", Grep: "grep" };
33
+
34
+ export interface MissCounts { compressed: number; misses: number }
35
+ /** stats[tool]["." + ext or "(none)"] */
36
+ export type MissStats = Record<string, Record<string, MissCounts>>;
37
+
38
+ interface Pending { t: string; tool: string; ext: string; turn: number; missed?: boolean }
39
+
40
+ export function trackingEnabled(): boolean {
41
+ return process.env.ASHLR_TUNING !== "off" && process.env.ASHLR_SESSION_LOG !== "0";
42
+ }
43
+
44
+ export function missStatsPath(homeDir: string): string {
45
+ return join(homeDir, ".ashlr", "miss-stats.json");
46
+ }
47
+ function pendingPath(homeDir: string, sessionId: string): string {
48
+ return join(homeDir, ".ashlr", "miss-pending", `${sessionId}.json`);
49
+ }
50
+
51
+ function readJson<T>(p: string, fallback: T): T {
52
+ try { return existsSync(p) ? (JSON.parse(readFileSync(p, "utf-8")) as T) : fallback; } catch { return fallback; }
53
+ }
54
+ function writeJson(p: string, v: unknown): void {
55
+ mkdirSync(join(p, ".."), { recursive: true });
56
+ writeFileSync(p, JSON.stringify(v), "utf-8");
57
+ }
58
+
59
+ /** Target path from a Read/Grep or ashlr__read/ashlr__grep tool_input. */
60
+ export function targetOf(input: unknown, cwd: string = process.cwd()): string | null {
61
+ if (!input || typeof input !== "object") return null;
62
+ const i = input as Record<string, unknown>;
63
+ const p = i.file_path ?? i.path;
64
+ if (typeof p !== "string" || !p.trim()) return null;
65
+ return resolve(cwd, p);
66
+ }
67
+
68
+ export function readMissStats(homeDir: string): MissStats {
69
+ return readJson<MissStats>(missStatsPath(homeDir), {});
70
+ }
71
+
72
+ /**
73
+ * Observe one completed tool call. `turn` is the session turn index of this
74
+ * call. Returns "compressed", "miss", or null (nothing recorded).
75
+ */
76
+ export function observeToolCall(
77
+ toolName: string,
78
+ toolInput: unknown,
79
+ turn: number,
80
+ sessionId: string,
81
+ homeDir: string,
82
+ cwd: string = process.cwd(),
83
+ ): "compressed" | "miss" | null {
84
+ if (!trackingEnabled()) return null;
85
+ try {
86
+ const kind = ASHLR_TOOLS[toolName] ?? NATIVE_TOOLS[toolName];
87
+ if (!kind) return null;
88
+ const target = targetOf(toolInput, cwd);
89
+ if (!target) return null;
90
+ const t = sha8(target);
91
+ const pp = pendingPath(homeDir, sessionId);
92
+ const pending = readJson<Pending[]>(pp, []).filter((e) => turn - e.turn <= MISS_WINDOW_TURNS);
93
+ const stats = readMissStats(homeDir);
94
+ const bump = (tool: string, ext: string, key: keyof MissCounts) => {
95
+ const row = (stats[tool] ??= {});
96
+ const c = (row[ext] ??= { compressed: 0, misses: 0 });
97
+ c[key]++;
98
+ };
99
+
100
+ let outcome: "compressed" | "miss" | null = null;
101
+ if (ASHLR_TOOLS[toolName]) {
102
+ const tool = `ashlr__${kind}`;
103
+ const ext = extname(target).toLowerCase() || "(none)";
104
+ pending.push({ t, tool, ext, turn });
105
+ bump(tool, ext, "compressed");
106
+ outcome = "compressed";
107
+ } else {
108
+ const hit = pending.find((e) => e.t === t && !e.missed && e.turn < turn);
109
+ if (hit) {
110
+ hit.missed = true;
111
+ bump(hit.tool, hit.ext, "misses");
112
+ outcome = "miss";
113
+ }
114
+ }
115
+ writeJson(pp, pending);
116
+ if (outcome) writeJson(missStatsPath(homeDir), stats);
117
+ return outcome;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Value-free detection of a `phantom exec -- <cmd>` session.
3
+ *
4
+ * `phantom exec` hands its child fresh `phm_` session tokens in place of real
5
+ * keys, points the provider SDK base URLs at a local proxy
6
+ * (`http://127.0.0.1:<port>/<route>/_phantom/<token>/`) and sets
7
+ * PHANTOM_PROXY_PORT, PHANTOM_PROXY_TOKEN and PHANTOM_SESSION_KEYS. The proxy
8
+ * swaps the token for the real key on the way out.
9
+ *
10
+ * This module only looks at variable NAMES, the `phm_` / `phm:` prefixes and
11
+ * the base URL host and port. It never resolves a reference, never runs
12
+ * `phantom reveal`, never returns a key value, and redacts the per-session
13
+ * proxy token from any URL it reports.
14
+ */
15
+
16
+ type Env = Record<string, string | undefined>;
17
+
18
+ /** Keys the plugin's scripts may send to a provider. */
19
+ export const PROVIDER_KEY_VARS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] as const;
20
+ /** Base URL variables `phantom exec` may set for those providers. */
21
+ export const PROVIDER_BASE_URL_VARS = ["ANTHROPIC_BASE_URL", "OPENAI_BASE_URL"] as const;
22
+
23
+ export type KeyKind = "unset" | "placeholder" | "ref" | "plain";
24
+
25
+ export interface PhantomExecReport {
26
+ /** True when PHANTOM_PROXY_PORT is a valid port and PHANTOM_PROXY_TOKEN is present. */
27
+ inSession: boolean;
28
+ proxyPort: number | null;
29
+ /** Per provider key: what kind of value it holds (never the value). */
30
+ keys: Record<string, KeyKind>;
31
+ /** Base URLs that point at this session's local proxy, token redacted. */
32
+ proxiedBaseUrls: Record<string, string>;
33
+ /** Plain-language warnings, value-free. */
34
+ warnings: string[];
35
+ }
36
+
37
+ /** Kind of value held by a key variable, without exposing it. */
38
+ export function keyKind(value: string | undefined): KeyKind {
39
+ const v = (value ?? "").trim();
40
+ if (!v) return "unset";
41
+ if (v.startsWith("phm_")) return "placeholder";
42
+ if (v.startsWith("phm:")) return "ref";
43
+ return "plain";
44
+ }
45
+
46
+ /** Replace the `/_phantom/<token>/` segment so a printed URL carries no token. */
47
+ export function redactProxyUrl(url: string): string {
48
+ return url.replace(/\/_phantom\/[^/]+/g, "/_phantom/<redacted>");
49
+ }
50
+
51
+ function validPort(raw: string | undefined): number | null {
52
+ if (!raw || !/^\d{1,5}$/.test(raw.trim())) return null;
53
+ const n = Number(raw.trim());
54
+ return n >= 1 && n <= 65535 ? n : null;
55
+ }
56
+
57
+ /** True when `url` is a loopback http URL on `port`. */
58
+ function pointsAtProxy(url: string, port: number): boolean {
59
+ try {
60
+ const u = new URL(url);
61
+ const host = u.hostname.replace(/^\[|\]$/g, "");
62
+ return (
63
+ u.protocol === "http:" &&
64
+ (host === "127.0.0.1" || host === "localhost" || host === "::1") &&
65
+ Number(u.port) === port
66
+ );
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export function detectPhantomExec(env: Env = process.env): PhantomExecReport {
73
+ const proxyPort = validPort(env["PHANTOM_PROXY_PORT"]);
74
+ const inSession = proxyPort !== null && !!(env["PHANTOM_PROXY_TOKEN"] ?? "").trim();
75
+ const keys: Record<string, KeyKind> = {};
76
+ for (const name of PROVIDER_KEY_VARS) keys[name] = keyKind(env[name]);
77
+ const proxiedBaseUrls: Record<string, string> = {};
78
+ if (inSession && proxyPort !== null) {
79
+ for (const name of PROVIDER_BASE_URL_VARS) {
80
+ const v = (env[name] ?? "").trim();
81
+ if (v && pointsAtProxy(v, proxyPort)) proxiedBaseUrls[name] = redactProxyUrl(v);
82
+ }
83
+ }
84
+
85
+ const warnings: string[] = [];
86
+ const baseFor: Record<string, string> = {
87
+ ANTHROPIC_API_KEY: "ANTHROPIC_BASE_URL",
88
+ OPENAI_API_KEY: "OPENAI_BASE_URL",
89
+ };
90
+ for (const name of PROVIDER_KEY_VARS) {
91
+ const kind = keys[name];
92
+ if (kind === "ref") {
93
+ warnings.push(
94
+ `${name} is a phm: reference. The plugin does not resolve references; run under \`phantom exec -- <cmd>\` so the variable holds a session placeholder routed by the proxy.`,
95
+ );
96
+ } else if (kind === "placeholder" && !inSession) {
97
+ warnings.push(
98
+ `${name} is a phm_ placeholder but this is not a phantom exec session, so a provider would reject it. Run under \`phantom exec -- <cmd>\`.`,
99
+ );
100
+ } else if (kind === "placeholder" && !proxiedBaseUrls[baseFor[name]!]) {
101
+ warnings.push(
102
+ `${name} is a phm_ placeholder but ${baseFor[name]} does not point at this session's proxy, so the placeholder would go to the provider directly. Check \`phantom why ${name}\`.`,
103
+ );
104
+ }
105
+ }
106
+ return { inSession, proxyPort, keys, proxiedBaseUrls, warnings };
107
+ }
108
+
109
+ /** One-line, value-free summary for doctor and the bench scripts. */
110
+ export function describePhantomExec(r: PhantomExecReport): string {
111
+ const placeholders = Object.entries(r.keys)
112
+ .filter(([, k]) => k === "placeholder")
113
+ .map(([n]) => n);
114
+ if (!r.inSession) {
115
+ return placeholders.length || Object.values(r.keys).includes("ref")
116
+ ? "not a phantom exec session (provider keys hold phm values; see warnings)"
117
+ : "not a phantom exec session";
118
+ }
119
+ const routed = Object.keys(r.proxiedBaseUrls);
120
+ return `phantom exec session on 127.0.0.1:${r.proxyPort}; placeholders: ${placeholders.join(", ") || "none"}; proxied: ${routed.join(", ") || "none"}`;
121
+ }
122
+
123
+ /**
124
+ * Whether a provider key is usable for a live call from this process: a plain
125
+ * key, or a placeholder inside a phantom exec session whose base URL routes
126
+ * through the proxy. Unset keys and phm: refs are not usable.
127
+ */
128
+ export function providerKeyUsable(name: (typeof PROVIDER_KEY_VARS)[number], env: Env = process.env): boolean {
129
+ const r = detectPhantomExec(env);
130
+ const kind = r.keys[name];
131
+ if (kind === "plain") return true;
132
+ if (kind !== "placeholder") return false;
133
+ const base = name === "ANTHROPIC_API_KEY" ? "ANTHROPIC_BASE_URL" : "OPENAI_BASE_URL";
134
+ return r.inSession && !!r.proxiedBaseUrls[base];
135
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Opt-in Phantom agent preflight for `ashlr doctor`, following phantom-secrets
3
+ * docs/agent-preflight-contract.md (schema_version 1). It runs
4
+ * `phantom agent doctor --json --strict`, which reads only project files and
5
+ * environment variables (no vault, no keychain) and prints a value-free
6
+ * verdict. `--strict` exits 0 ready, 2 degraded, 1 blocked, 3 could not
7
+ * evaluate.
8
+ *
9
+ * Off unless ASHLR_PHANTOM_PREFLIGHT is set:
10
+ * 1 / true / on blocked is a fail; degraded or unknown is a warning
11
+ * strict anything but ready is a fail
12
+ * A missing phantom, or one without `agent doctor --json`, is only a warning.
13
+ * Output is the verdict and kebab-case check ids; never names or values.
14
+ */
15
+
16
+ import { spawnSync } from "child_process";
17
+
18
+ export type PreflightMode = "off" | "on" | "strict";
19
+ export type Verdict = "ready" | "degraded" | "blocked" | "unknown";
20
+ export type PreflightRunner = () => {
21
+ status: number | null;
22
+ stdout: string;
23
+ missing: boolean;
24
+ };
25
+
26
+ export interface PreflightResult {
27
+ status: "ok" | "warn" | "fail";
28
+ verdict: Verdict | null;
29
+ /** Ids of the critical checks (`wrap.blocked_by`). Ids only. */
30
+ blockedBy: string[];
31
+ detail: string;
32
+ }
33
+
34
+ export function preflightMode(
35
+ env: Record<string, string | undefined> = process.env,
36
+ ): PreflightMode {
37
+ const v = (env["ASHLR_PHANTOM_PREFLIGHT"] ?? "").trim().toLowerCase();
38
+ if (v === "strict") return "strict";
39
+ if (v === "1" || v === "true" || v === "on") return "on";
40
+ return "off";
41
+ }
42
+
43
+ export const defaultPreflightRunner: PreflightRunner = () => {
44
+ try {
45
+ const r = spawnSync("phantom", ["agent", "doctor", "--json", "--strict"], {
46
+ encoding: "utf8",
47
+ timeout: 15_000,
48
+ stdio: ["ignore", "pipe", "ignore"],
49
+ });
50
+ // Some Bun versions set `error` on a normal exit, so trust `status` first.
51
+ const missing =
52
+ r.status === null &&
53
+ (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
54
+ return { status: r.status ?? null, stdout: r.stdout ?? "", missing };
55
+ } catch {
56
+ return { status: null, stdout: "", missing: true };
57
+ }
58
+ };
59
+
60
+ const EXIT_CODES = new Set([0, 1, 2, 3]);
61
+ const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
62
+ const VERDICTS: readonly Verdict[] = [
63
+ "ready",
64
+ "degraded",
65
+ "blocked",
66
+ "unknown",
67
+ ];
68
+
69
+ function parse(
70
+ stdout: string,
71
+ ): { verdict: Verdict; blockedBy: string[] } | null {
72
+ try {
73
+ const doc = JSON.parse(stdout) as {
74
+ schema_version?: unknown;
75
+ kind?: unknown;
76
+ verdict?: unknown;
77
+ wrap?: { blocked_by?: unknown };
78
+ };
79
+ if (doc.kind !== "phantom.agent.preflight" || doc.schema_version !== 1)
80
+ return null;
81
+ if (!VERDICTS.includes(doc.verdict as Verdict)) return null;
82
+ const ids = Array.isArray(doc.wrap?.blocked_by) ? doc.wrap.blocked_by : [];
83
+ const blockedBy = ids.filter(
84
+ (id): id is string => typeof id === "string" && ID.test(id),
85
+ );
86
+ return { verdict: doc.verdict as Verdict, blockedBy };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /** Run the preflight; null when it is off. */
93
+ export function runPhantomPreflight(
94
+ mode: PreflightMode,
95
+ run: PreflightRunner = defaultPreflightRunner,
96
+ ): PreflightResult | null {
97
+ if (mode === "off") return null;
98
+ const r = run();
99
+ if (r.missing) {
100
+ return {
101
+ status: "warn",
102
+ verdict: null,
103
+ blockedBy: [],
104
+ detail: "skipped: phantom is not installed",
105
+ };
106
+ }
107
+ const parsed = parse(r.stdout);
108
+ if (!parsed || r.status === null || !EXIT_CODES.has(r.status)) {
109
+ return {
110
+ status: "warn",
111
+ verdict: null,
112
+ blockedBy: [],
113
+ detail: "skipped: this phantom has no `agent doctor --json` (schema 1)",
114
+ };
115
+ }
116
+ const { verdict, blockedBy } = parsed;
117
+ const status =
118
+ verdict === "ready"
119
+ ? "ok"
120
+ : verdict === "blocked" || mode === "strict"
121
+ ? "fail"
122
+ : "warn";
123
+ const ids = blockedBy.length ? ` (${blockedBy.join(", ")})` : "";
124
+ return {
125
+ status,
126
+ verdict,
127
+ blockedBy,
128
+ detail: `${verdict}${verdict === "blocked" ? ids : ""}`,
129
+ };
130
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * _pid-alive.ts — "is this PID still a running process?"
3
+ *
4
+ * `process.kill(pid, 0)` succeeds for a zombie: a process that has exited but
5
+ * whose parent has not reaped it. Containers whose PID 1 is not an init that
6
+ * reaps orphans (common for agent sandboxes) keep every orphaned exit as a
7
+ * zombie, so a kill-only check reports long-dead background jobs and lock
8
+ * owners as alive forever. On Linux, read the state letter from
9
+ * /proc/<pid>/stat and treat Z (zombie) and X (dead) as gone.
10
+ */
11
+
12
+ import { readFileSync } from "fs";
13
+
14
+ /**
15
+ * True when `pid` names a live, non-zombie process. EPERM (the process exists
16
+ * but belongs to another user) counts as alive.
17
+ */
18
+ export function pidAlive(pid: number): boolean {
19
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
20
+ try {
21
+ process.kill(pid, 0);
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
24
+ }
25
+ return !isLinuxZombie(pid);
26
+ }
27
+
28
+ function isLinuxZombie(pid: number): boolean {
29
+ if (process.platform !== "linux") return false;
30
+ try {
31
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
32
+ // Format: "pid (comm) S ...". comm may contain spaces or parentheses, so
33
+ // the state is the first field after the last ")".
34
+ const state = stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3);
35
+ return state === "Z" || state === "X";
36
+ } catch {
37
+ // /proc unreadable (hidepid, exotic mounts): fall back to the kill result.
38
+ return false;
39
+ }
40
+ }