pi-ast-sgrep 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +16 -19
  2. package/dist/code-mode.d.ts +1 -1
  3. package/dist/code-mode.js +1 -1
  4. package/dist/codemode/connector.d.ts +18 -3
  5. package/dist/codemode/connector.js +85 -31
  6. package/dist/codemode/dispatch.d.ts +13 -1
  7. package/dist/codemode/dispatch.js +87 -24
  8. package/dist/codemode/guest-api.d.ts +16 -0
  9. package/dist/codemode/guest-api.js +194 -0
  10. package/dist/codemode/guest-worker.mjs +287 -0
  11. package/dist/codemode/index.d.ts +4 -3
  12. package/dist/codemode/index.js +4 -3
  13. package/dist/codemode/native.d.ts +1 -1
  14. package/dist/codemode/native.js +1 -1
  15. package/dist/codemode/runner.d.ts +13 -9
  16. package/dist/codemode/runner.js +411 -213
  17. package/dist/codemode/session-pool.d.ts +6 -1
  18. package/dist/codemode/session-pool.js +125 -32
  19. package/dist/codemode/types.d.ts +42 -2
  20. package/dist/codemode/types.js +40 -15
  21. package/dist/codemode/worker.d.ts +1 -1
  22. package/dist/codemode/worker.js +25 -2
  23. package/dist/host/commands.d.ts +6 -0
  24. package/dist/host/commands.js +49 -0
  25. package/dist/host/results.d.ts +123 -0
  26. package/dist/host/results.js +126 -0
  27. package/dist/host/tools.d.ts +28 -0
  28. package/dist/host/tools.js +802 -0
  29. package/dist/index.d.ts +7 -34
  30. package/dist/index.js +5 -543
  31. package/dist/runtime/config.d.ts +36 -0
  32. package/dist/runtime/config.js +98 -0
  33. package/dist/runtime/freshness.d.ts +43 -0
  34. package/dist/runtime/freshness.js +446 -0
  35. package/dist/runtime/index-health.d.ts +16 -0
  36. package/dist/runtime/index-health.js +111 -0
  37. package/dist/runtime/runtime.d.ts +48 -0
  38. package/dist/runtime/runtime.js +265 -0
  39. package/dist/runtime/sqlite.d.ts +15 -0
  40. package/dist/runtime/sqlite.js +63 -0
  41. package/dist/runtime/types.d.ts +55 -0
  42. package/dist/runtime/types.js +25 -0
  43. package/dist/ui/card.d.ts +66 -0
  44. package/dist/ui/card.js +375 -0
  45. package/dist/ui/present.d.ts +89 -0
  46. package/dist/ui/present.js +391 -0
  47. package/package.json +8 -7
  48. package/dist/codemode/sandbox-worker.d.ts +0 -1
  49. package/dist/codemode/sandbox-worker.js +0 -204
  50. package/dist/present.d.ts +0 -70
  51. package/dist/present.js +0 -260
  52. package/dist/runtime.d.ts +0 -137
  53. package/dist/runtime.js +0 -799
@@ -7,7 +7,7 @@
7
7
  * Fallback: sticky `codemode-serve` child only when the `.node` addon is missing
8
8
  * (unsupported host / incomplete install). Doctor reports that as degraded.
9
9
  */
10
- import type { MachineEnvelope } from "../runtime.js";
10
+ import type { MachineEnvelope } from "../runtime/runtime.js";
11
11
  import { type StickyWorker } from "./dispatch.js";
12
12
  import { type StickyWorkerOptions } from "./worker.js";
13
13
  export type SessionPoolOptions = {
@@ -22,6 +22,9 @@ export type SessionPoolOptions = {
22
22
  limit?: number;
23
23
  };
24
24
  export type StickyStarter = (options: StickyWorkerOptions) => Promise<StickyWorker>;
25
+ /** Unique search must not run on the JS thread; cache hits may. */
26
+ export declare function isUncachedSearchError(cause: unknown): boolean;
27
+ export declare function isClosedWorkerError(cause: unknown): boolean;
25
28
  export declare class NativeSessionPool {
26
29
  #private;
27
30
  constructor(startFn?: StickyStarter);
@@ -29,6 +32,8 @@ export declare class NativeSessionPool {
29
32
  configured(): boolean;
30
33
  /** Active backend after first successful acquire. */
31
34
  backend(): "napi" | "cli" | "none";
35
+ /** Why the last start attempt failed — for doctor/status and error fidelity. */
36
+ lastStartError(root: string): string | undefined;
32
37
  acquire(root: string): Promise<StickyWorker | null>;
33
38
  call(root: string, tool: string, args?: Record<string, unknown>, options?: {
34
39
  signal?: AbortSignal;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { asEnvelope } from "./dispatch.js";
11
11
  import { loadCodemodeNative } from "./native.js";
12
+ import { defined } from "./types.js";
12
13
  import { startStickyWorker } from "./worker.js";
13
14
  const abortError = () => Object.assign(new Error("native call aborted"), { name: "AbortError" });
14
15
  /** Bounded metadata and symbol lookups that may run on the JS thread. */
@@ -19,11 +20,43 @@ const FAST_LOOKUP = new Set([
19
20
  "index_status",
20
21
  "catalog_search",
21
22
  "catalog_describe",
23
+ "find",
24
+ "read",
22
25
  ]);
23
26
  function isBusyError(cause) {
24
27
  const message = cause instanceof Error ? cause.message : String(cause);
25
28
  return /session is busy/i.test(message);
26
29
  }
30
+ /** Unique search must not run on the JS thread; cache hits may. */
31
+ export function isUncachedSearchError(cause) {
32
+ const message = cause instanceof Error ? cause.message : String(cause);
33
+ return /use call\(\) for search/i.test(message);
34
+ }
35
+ /** NAPI can surface SQLite u64 counters as BigInt (writer_generation exceeds
36
+ * 2^53). BigInt breaks JSON.stringify downstream (pi serializes result
37
+ * details) — normalize to Number at the boundary. Precision past 2^53 is
38
+ * display-only here; equality comparisons still hold since both sides
39
+ * convert the same integer identically. */
40
+ function normalizeNativeValue(value) {
41
+ if (typeof value === "bigint")
42
+ return Number(value);
43
+ if (Array.isArray(value))
44
+ return value.map(normalizeNativeValue);
45
+ if (value && typeof value === "object") {
46
+ const out = {};
47
+ for (const [key, entry] of Object.entries(value))
48
+ out[key] = normalizeNativeValue(entry);
49
+ return out;
50
+ }
51
+ return value;
52
+ }
53
+ export function isClosedWorkerError(cause) {
54
+ const message = cause instanceof Error ? cause.message : String(cause);
55
+ return /codemode-serve is closed|native session is closed/i.test(message);
56
+ }
57
+ function workerIsClosed(worker) {
58
+ return worker.closed?.() === true;
59
+ }
27
60
  function inProcessWorker(session) {
28
61
  let tail = Promise.resolve();
29
62
  let closed = false;
@@ -53,26 +86,34 @@ function inProcessWorker(session) {
53
86
  });
54
87
  };
55
88
  return {
89
+ closed: () => closed,
56
90
  call(tool, args, options) {
57
91
  if (options?.signal?.aborted)
58
92
  return Promise.reject(abortError());
59
93
  const sync = session.callNow;
60
- if (inflight === 0 && !closed && sync && FAST_LOOKUP.has(tool)) {
94
+ if (inflight === 0 && !closed && sync && (FAST_LOOKUP.has(tool) || tool === "search")) {
61
95
  try {
62
- return Promise.resolve(asEnvelope(sync.call(session, tool, args ?? {}), tool));
96
+ const value = sync.call(session, tool, args ?? {});
97
+ if (!(tool === "search" && value == null)) {
98
+ return Promise.resolve(asEnvelope(normalizeNativeValue(value), tool));
99
+ }
63
100
  }
64
101
  catch (cause) {
65
- if (!isBusyError(cause))
102
+ if (tool === "search" && isUncachedSearchError(cause)) {
103
+ // Older native addons threw on unique search.
104
+ }
105
+ else if (!isBusyError(cause)) {
66
106
  return Promise.reject(cause);
107
+ }
67
108
  }
68
109
  }
69
- return enqueue(async () => asEnvelope(await session.call(tool, args, options?.signal), tool), options?.signal);
110
+ return enqueue(async () => asEnvelope(normalizeNativeValue(await session.call(tool, args, options?.signal)), tool), options?.signal);
70
111
  },
71
112
  batch(calls, options) {
72
113
  return enqueue(async () => {
73
114
  const response = await session.batch(calls, options?.signal);
74
115
  const result = {
75
- results: response.results,
116
+ results: normalizeNativeValue(response.results),
76
117
  all_ok: response.allOk,
77
118
  wall_ms: response.wallMs,
78
119
  mode: response.mode,
@@ -95,6 +136,10 @@ export class NativeSessionPool {
95
136
  #startFn;
96
137
  #backend = "none";
97
138
  #shutdownPromise = null;
139
+ /** Real start failure per root — surfaced instead of a stale "closed" error. */
140
+ #startFailures = new Map();
141
+ /** Crash-loop guard: a failing backend gets this long before respawn retries. */
142
+ static #RESTART_BACKOFF_MS = 15_000;
98
143
  constructor(startFn = startStickyWorker) {
99
144
  this.#startFn = startFn;
100
145
  }
@@ -108,12 +153,26 @@ export class NativeSessionPool {
108
153
  backend() {
109
154
  return this.#backend;
110
155
  }
156
+ /** Why the last start attempt failed — for doctor/status and error fidelity. */
157
+ lastStartError(root) {
158
+ return this.#startFailures.get(root)?.error;
159
+ }
111
160
  async acquire(root) {
112
161
  if (this.#shutdownPromise)
113
162
  return null;
114
163
  const existing = this.#entries.get(root);
115
- if (existing)
116
- return existing.worker;
164
+ if (existing) {
165
+ if (!workerIsClosed(existing.worker))
166
+ return existing.worker;
167
+ await this.invalidate(root);
168
+ }
169
+ // A backend that just failed is crash-looping: skip the respawn for a
170
+ // bounded window so callers fall back instead of paying a doomed spawn
171
+ // per call. The window expires and recovery still happens automatically.
172
+ const failure = this.#startFailures.get(root);
173
+ if (failure && Date.now() - failure.at < NativeSessionPool.#RESTART_BACKOFF_MS) {
174
+ return null;
175
+ }
117
176
  const inFlight = this.#starting.get(root);
118
177
  if (inFlight)
119
178
  return inFlight;
@@ -130,10 +189,31 @@ export class NativeSessionPool {
130
189
  async call(root, tool, args = {}, options) {
131
190
  if (options?.signal?.aborted)
132
191
  throw abortError();
133
- const worker = await this.acquire(root);
134
- if (!worker)
135
- throw new Error("native Code Mode backend unavailable");
136
- return worker.call(tool, args, options);
192
+ try {
193
+ const worker = await this.acquire(root);
194
+ if (!worker) {
195
+ const startError = this.lastStartError(root);
196
+ throw new Error(startError
197
+ ? "codemode backend unavailable: " + startError
198
+ : "native Code Mode backend unavailable");
199
+ }
200
+ return await worker.call(tool, args, options);
201
+ }
202
+ catch (cause) {
203
+ if (options?.signal?.aborted || !isClosedWorkerError(cause))
204
+ throw cause;
205
+ await this.invalidate(root);
206
+ const retry = await this.acquire(root);
207
+ if (!retry) {
208
+ // Surface the real restart failure (spawn error / schema refusal), not
209
+ // the stale "closed" message that hides it.
210
+ const startError = this.lastStartError(root);
211
+ if (startError)
212
+ throw new Error("codemode backend unavailable: " + startError);
213
+ throw cause;
214
+ }
215
+ return retry.call(tool, args, options);
216
+ }
137
217
  }
138
218
  async invalidate(root) {
139
219
  this.#generations.set(root, this.#generationFor(root) + 1);
@@ -141,6 +221,7 @@ export class NativeSessionPool {
141
221
  this.#starting.delete(root);
142
222
  const entry = this.#entries.get(root);
143
223
  this.#entries.delete(root);
224
+ this.#startFailures.delete(root);
144
225
  if (entry)
145
226
  await entry.worker.end().catch(() => undefined);
146
227
  if (starting)
@@ -181,17 +262,24 @@ export class NativeSessionPool {
181
262
  async #start(root) {
182
263
  const gen = this.#generationFor(root);
183
264
  const opts = this.#options ?? {};
265
+ const fail = (error) => {
266
+ this.#startFailures.set(root, {
267
+ at: Date.now(),
268
+ error: error instanceof Error ? error.message : String(error),
269
+ });
270
+ return null;
271
+ };
184
272
  // 1) In-process NAPI (preferred — zero spawn).
185
273
  const binding = loadCodemodeNative();
274
+ let napiError = null;
186
275
  if (binding) {
187
276
  try {
188
- const config = { root };
189
- if (opts.indexPath)
190
- config.indexPath = opts.indexPath;
191
- if (opts.limit !== undefined)
192
- config.limit = opts.limit;
193
- if (opts.useEmbed !== undefined)
194
- config.useEmbed = opts.useEmbed;
277
+ const config = defined({
278
+ root,
279
+ indexPath: opts.indexPath,
280
+ limit: opts.limit,
281
+ useEmbed: opts.useEmbed,
282
+ });
195
283
  const session = new binding.Session(config);
196
284
  const worker = inProcessWorker(session);
197
285
  if (gen !== this.#generationFor(root)) {
@@ -200,37 +288,42 @@ export class NativeSessionPool {
200
288
  }
201
289
  this.#entries.set(root, { root, worker, generation: gen, backend: "napi" });
202
290
  this.#backend = "napi";
291
+ this.#startFailures.delete(root);
203
292
  return worker;
204
293
  }
205
- catch {
206
- // Fall through to CLI sticky.
294
+ catch (cause) {
295
+ // Fall through to CLI sticky, but keep the real error for fidelity.
296
+ napiError = cause;
207
297
  }
208
298
  }
209
299
  // 2) CLI sticky fallback (degraded).
210
- if (!opts.binary)
211
- return null;
300
+ if (!opts.binary) {
301
+ return fail(napiError ?? new Error("native Code Mode backend unavailable (no addon, no binary)"));
302
+ }
212
303
  try {
213
- const stickyOpts = {
304
+ const stickyOpts = defined({
214
305
  binary: opts.binary,
215
306
  cwd: root,
216
- };
217
- if (opts.env)
218
- stickyOpts.env = opts.env;
219
- if (opts.timeoutMs !== undefined)
220
- stickyOpts.timeoutMs = opts.timeoutMs;
221
- if (opts.maxOutputBytes !== undefined)
222
- stickyOpts.maxOutputBytes = opts.maxOutputBytes;
307
+ env: opts.env,
308
+ timeoutMs: opts.timeoutMs,
309
+ maxOutputBytes: opts.maxOutputBytes,
310
+ });
223
311
  const worker = await this.#startFn(stickyOpts);
224
312
  if (gen !== this.#generationFor(root)) {
225
313
  await worker.end().catch(() => undefined);
226
314
  return null;
227
315
  }
316
+ if (workerIsClosed(worker)) {
317
+ await worker.end().catch(() => undefined);
318
+ return fail(new Error("codemode-serve exited during startup"));
319
+ }
228
320
  this.#entries.set(root, { root, worker, generation: gen, backend: "cli" });
229
321
  this.#backend = "cli";
322
+ this.#startFailures.delete(root);
230
323
  return worker;
231
324
  }
232
- catch {
233
- return null;
325
+ catch (cause) {
326
+ return fail(cause);
234
327
  }
235
328
  }
236
329
  }
@@ -1,18 +1,58 @@
1
1
  /** Typed surface the model sees inside a Code Mode program (`asgrep.*`). */
2
+ /** Drop undefined keys — replaces spread-conditional arg-building chains. */
3
+ export declare function defined<T extends Record<string, unknown>>(args: T): Record<string, unknown>;
2
4
  export type SearchArgs = {
3
5
  query: string;
4
6
  limit?: number;
5
7
  excerptLines?: number;
6
8
  format?: "capsule" | "agent";
9
+ /** Directory or glob; injected as an `in:` query token. */
10
+ in?: string;
11
+ fileFilter?: string;
12
+ file_filter?: string;
13
+ lang?: string;
14
+ };
15
+ export type FindArgs = SearchArgs;
16
+ export type ReadArgs = {
17
+ path?: string;
18
+ start?: number;
19
+ end?: number;
20
+ ref?: string;
21
+ refs?: unknown[];
22
+ contextLines?: number;
23
+ maxChars?: number;
24
+ };
25
+ export type EditArgs = {
26
+ path?: string;
27
+ oldText?: string;
28
+ newText?: string;
29
+ edits?: Array<{
30
+ path?: string;
31
+ oldText: string;
32
+ newText: string;
33
+ }>;
7
34
  };
8
35
  export type ChainArgs = {
9
36
  query: string;
10
37
  limit?: number;
11
38
  excerptLines?: number;
12
39
  };
40
+ /** Host methods the program may invoke. Primary lookup methods first. */
41
+ export declare const CODEMODE_HOST_METHODS: readonly ["search", "find", "read", "edit", "semantic", "chain", "defs", "callers", "imports", "indexStatus", "indexRepo", "doctor", "catalogSearch", "catalogDescribe"];
42
+ export type CodemodeHostMethod = (typeof CODEMODE_HOST_METHODS)[number];
13
43
  /**
14
44
  * Compact TypeScript declarations for the `asgrep` tool description.
15
- * Keep short — every token here is paid on every turn (schema landfill lesson
16
- * from pi-codex-conversion: compose inside Code Mode, don't dump 17 schemas).
45
+ * Four commands only — every token here is paid on every turn.
46
+ * Return shapes are muscle memory (Blacksmith): field names, never values.
47
+ * defs:/callers:/imports:/pattern:/blast: go through find or search prefixes.
48
+ */
49
+ /**
50
+ * Always-on API cheat sheet for the Code Mode tool description.
51
+ *
52
+ * Deliberately minimal: every token here rides in the system prompt of every
53
+ * request. The full per-method schema is one call away through
54
+ * `asgrep.catalogSearch(query)` / `asgrep.catalogDescribe(name)`, which returns
55
+ * the same shapes from the native catalog, so the model pays for the reference
56
+ * only when it needs it.
17
57
  */
18
58
  export declare const CODEMODE_TYPES_FOR_MODEL: string;
@@ -1,21 +1,46 @@
1
1
  /** Typed surface the model sees inside a Code Mode program (`asgrep.*`). */
2
+ /** Drop undefined keys — replaces spread-conditional arg-building chains. */
3
+ export function defined(args) {
4
+ const out = {};
5
+ for (const [key, value] of Object.entries(args))
6
+ if (value !== undefined)
7
+ out[key] = value;
8
+ return out;
9
+ }
10
+ /** Host methods the program may invoke. Primary lookup methods first. */
11
+ export const CODEMODE_HOST_METHODS = [
12
+ "search",
13
+ "find",
14
+ "read",
15
+ "edit",
16
+ "semantic",
17
+ "chain",
18
+ "defs",
19
+ "callers",
20
+ "imports",
21
+ "indexStatus",
22
+ "indexRepo",
23
+ "doctor",
24
+ "catalogSearch",
25
+ "catalogDescribe",
26
+ ];
2
27
  /**
3
28
  * Compact TypeScript declarations for the `asgrep` tool description.
4
- * Keep short — every token here is paid on every turn (schema landfill lesson
5
- * from pi-codex-conversion: compose inside Code Mode, don't dump 17 schemas).
29
+ * Four commands only — every token here is paid on every turn.
30
+ * Return shapes are muscle memory (Blacksmith): field names, never values.
31
+ * defs:/callers:/imports:/pattern:/blast: go through find or search prefixes.
32
+ */
33
+ /**
34
+ * Always-on API cheat sheet for the Code Mode tool description.
35
+ *
36
+ * Deliberately minimal: every token here rides in the system prompt of every
37
+ * request. The full per-method schema is one call away through
38
+ * `asgrep.catalogSearch(query)` / `asgrep.catalogDescribe(name)`, which returns
39
+ * the same shapes from the native catalog, so the model pays for the reference
40
+ * only when it needs it.
6
41
  */
7
42
  export const CODEMODE_TYPES_FOR_MODEL = `
8
- declare const asgrep: {
9
- search(input: { query: string; limit?: number; excerptLines?: number }): Promise<unknown>;
10
- semantic(input: { query: string; limit?: number; excerptLines?: number }): Promise<unknown>;
11
- chain(input: { query: string; limit?: number }): Promise<unknown>;
12
- defs(input: { symbol: string; limit?: number }): Promise<unknown>;
13
- callers(input: { symbol: string; limit?: number }): Promise<unknown>;
14
- imports(input: { module: string; limit?: number }): Promise<unknown>;
15
- indexStatus(): Promise<unknown>;
16
- indexRepo(input?: { force?: boolean }): Promise<unknown>;
17
- catalogSearch(input: { query: string }): Promise<unknown>;
18
- catalogDescribe(input: { name: string }): Promise<unknown>;
19
- };
20
- /** JS: Promise, JSON, Array, Object, Map, Set, Math. No require/process/fetch/fs. */
43
+ asgrep.search(query|{query,in,lang,limit,excerptLines}) | find(q) | semantic(q) | defs(sym) | callers(sym)
44
+ | imports(mod) | chain(q) | read({path|ref|refs,start,end}) | edit({path,oldText,newText}|{edits}) | indexStatus()
45
+ hits[{file,ref,symbol,kind,preview}] | windows[{path,start,end,text}] | catalogDescribe("name") for schemas
21
46
  `.trim();
@@ -4,7 +4,7 @@
4
4
  * One process, one warm Searcher, for the entire Code Mode program — the biggest
5
5
  * Amdahl win over per-wave `codemode-batch` spawns.
6
6
  */
7
- import type { MachineEnvelope } from "../runtime.js";
7
+ import type { MachineEnvelope } from "../runtime/runtime.js";
8
8
  import { type StickyWorker } from "./dispatch.js";
9
9
  export type StickyWorkerOptions = {
10
10
  binary: string;
@@ -16,9 +16,26 @@ export async function startStickyWorker(options) {
16
16
  env: { ...process.env, ...options.env, NO_COLOR: "1" },
17
17
  stdio: ["pipe", "pipe", "pipe"],
18
18
  });
19
+ // Fail fast on spawn errors (ENOENT / EACCES): callers get the spawn error
20
+ // now instead of a dead transport that only fails on first use.
21
+ await new Promise((resolve, reject) => {
22
+ const onError = (err) => {
23
+ child.removeListener("spawn", onSpawn);
24
+ reject(err);
25
+ };
26
+ const onSpawn = () => {
27
+ child.removeListener("error", onError);
28
+ resolve();
29
+ };
30
+ child.once("error", onError);
31
+ child.once("spawn", onSpawn);
32
+ });
19
33
  const pending = new Map();
20
34
  let nextId = 0;
21
35
  let closed = false;
36
+ // The real termination cause (exit code + stderr) — callers that hit a dead
37
+ // transport must see WHY it died, not a bare "is closed".
38
+ let deadCause = null;
22
39
  let stderr = "";
23
40
  let stdout = Buffer.alloc(0);
24
41
  const decoder = new TextDecoder("utf-8", { fatal: true });
@@ -32,6 +49,7 @@ export async function startStickyWorker(options) {
32
49
  if (closed)
33
50
  return;
34
51
  closed = true;
52
+ deadCause ??= err;
35
53
  options.signal?.removeEventListener("abort", onAbort);
36
54
  killChild(child);
37
55
  failAll(err);
@@ -112,15 +130,19 @@ export async function startStickyWorker(options) {
112
130
  child.on("close", (code, signal) => {
113
131
  closed = true;
114
132
  options.signal?.removeEventListener("abort", onAbort);
133
+ deadCause ??= new Error(`codemode-serve exited code=${code ?? "null"} signal=${signal ?? "null"} stderr=${stderr.slice(0, 512)}`);
115
134
  if (pending.size > 0) {
116
- failAll(new Error(`codemode-serve exited code=${code ?? "null"} signal=${signal ?? "null"} stderr=${stderr.slice(0, 512)}`));
135
+ failAll(deadCause);
117
136
  }
118
137
  });
119
138
  const onAbort = () => terminate(new Error("codemode-serve aborted"));
120
139
  options.signal?.addEventListener("abort", onAbort, { once: true });
140
+ const closedError = () => deadCause
141
+ ? new Error("codemode-serve is closed (" + deadCause.message + ")")
142
+ : new Error("codemode-serve is closed");
121
143
  const write = (payload) => {
122
144
  if (closed || !child.stdin.writable) {
123
- return Promise.reject(new Error("codemode-serve is closed"));
145
+ return Promise.reject(closedError());
124
146
  }
125
147
  const id = typeof payload.id === "string" ? payload.id : String(nextId++);
126
148
  payload.id = id;
@@ -168,6 +190,7 @@ export async function startStickyWorker(options) {
168
190
  // Probe: empty End would close — instead send a tiny catalog call to verify protocol,
169
191
  // or just return and let first real call fail. Prefer lazy: no probe.
170
192
  return {
193
+ closed: () => closed || !child.stdin.writable,
171
194
  async call(tool, args, callOptions) {
172
195
  const msg = await writeWithControls({ type: "call", tool, args }, "codemode call", callOptions?.signal);
173
196
  if (msg.type === "error") {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Slash commands: /asgrep-doctor /asgrep-status /asgrep-index /asgrep-reindex.
3
+ */
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { type RuntimeLike } from "./results.js";
6
+ export declare function registerAstSgrepCommands(pi: ExtensionAPI, runtime?: RuntimeLike): void;
@@ -0,0 +1,49 @@
1
+ import { AstSgrepRuntime } from "../runtime/runtime.js";
2
+ import { bounded, errorDetails } from "./results.js";
3
+ const COMMANDS = [
4
+ ["asgrep-doctor", "Check the ast-sgrep runtime, native binary, index, and project configuration", "doctor"],
5
+ ["asgrep-status", "Show ast-sgrep runtime, index, backend, and capability status", "status"],
6
+ ["asgrep-index", "Build the ast-sgrep index for the current project", "index"],
7
+ ["asgrep-reindex", "Rebuild the ast-sgrep index for the current project", "reindex"],
8
+ ];
9
+ async function runCommand(runtime, command, ctx, args) {
10
+ if (args.trim() !== "") {
11
+ return {
12
+ ok: false,
13
+ command,
14
+ error: { code: "INVALID_ARGUMENTS", message: `/${command} does not accept arguments`, details: { args } },
15
+ };
16
+ }
17
+ try {
18
+ const response = await runtime.run([command.slice("asgrep-".length), ".", "--json"], { cwd: ctx.cwd });
19
+ return { ok: true, command, response };
20
+ }
21
+ catch (cause) {
22
+ return { ok: false, command, error: errorDetails(cause) };
23
+ }
24
+ }
25
+ function compactCommandResult(result) {
26
+ if (!result.ok)
27
+ return `${result.command} failed [${result.error.code}]: ${result.error.message}`;
28
+ const response = result.response;
29
+ const counts = response.counts && typeof response.counts === "object"
30
+ ? Object.entries(response.counts).map(([key, value]) => `${key}=${String(value)}`).join(" ")
31
+ : "";
32
+ const state = typeof response.status === "string" ? response.status
33
+ : typeof response.index_status === "string" ? response.index_status
34
+ : response.ok ? "healthy" : "failed";
35
+ return bounded([`${result.command}: ${state}`, counts].filter(Boolean).join(" · "));
36
+ }
37
+ export function registerAstSgrepCommands(pi, runtime = new AstSgrepRuntime(pi)) {
38
+ for (const [name, description] of COMMANDS) {
39
+ pi.registerCommand(name, {
40
+ description,
41
+ async handler(args, context) {
42
+ const ctx = context;
43
+ const result = await runCommand(runtime, name, ctx, args);
44
+ const output = ctx.hasUI ? compactCommandResult(result) : JSON.stringify(result);
45
+ ctx.ui.notify(output, result.ok ? "info" : "error");
46
+ },
47
+ });
48
+ }
49
+ }
@@ -0,0 +1,123 @@
1
+ import { type MachineEnvelope, type RunOptions } from "../runtime/types.js";
2
+ import type { FreshnessCoordinator } from "../runtime/freshness.js";
3
+ export declare const MAX_CONTENT_CHARS = 8000;
4
+ export type RuntimeLike = {
5
+ run(args: readonly string[], context: {
6
+ cwd: string;
7
+ }, options?: RunOptions): Promise<MachineEnvelope>;
8
+ resolveRoot?(context: {
9
+ cwd: string;
10
+ }): Promise<string>;
11
+ resolveBinaryPath?(options?: {
12
+ env?: NodeJS.ProcessEnv;
13
+ }): string;
14
+ nativeEnv?(options?: {
15
+ env?: NodeJS.ProcessEnv;
16
+ }): NodeJS.ProcessEnv;
17
+ config?: {
18
+ timeoutMs?: number;
19
+ maxOutputBytes?: number;
20
+ refreshIntervalMs?: number;
21
+ };
22
+ inspectIndexCompatibility?(context: {
23
+ cwd: string;
24
+ }): Promise<"ready" | "missing" | "incompatible">;
25
+ rebuildIncompatibleIndex?(context: {
26
+ cwd: string;
27
+ }, options?: RunOptions): Promise<MachineEnvelope>;
28
+ resolveIndexPath?(root: string): string;
29
+ watchExternalChanges?: boolean;
30
+ };
31
+ export type FreshnessLike = Pick<FreshnessCoordinator, "ensureFresh" | "markAffectedPath"> & {
32
+ markRootDirty?(root: string): void;
33
+ shutdown?(): void;
34
+ };
35
+ export type ToolContext = {
36
+ cwd: string;
37
+ };
38
+ export type CommandContext = ToolContext & {
39
+ hasUI: boolean;
40
+ ui: {
41
+ notify(message: string, type?: "info" | "warning" | "error"): void;
42
+ };
43
+ };
44
+ export type CommandResult = {
45
+ ok: true;
46
+ command: string;
47
+ response: MachineEnvelope;
48
+ } | {
49
+ ok: false;
50
+ command: string;
51
+ error: {
52
+ code: string;
53
+ message: string;
54
+ details: Readonly<Record<string, unknown>>;
55
+ };
56
+ };
57
+ export type Update = (result: {
58
+ content: Array<{
59
+ type: "text";
60
+ text: string;
61
+ }>;
62
+ details: Record<string, unknown>;
63
+ }) => void;
64
+ export declare function bounded(text: string): string;
65
+ export declare function success(command: string, response: MachineEnvelope, extra?: {
66
+ query?: string;
67
+ mode?: string;
68
+ activationMs?: number;
69
+ backend?: string;
70
+ freshness?: "stale";
71
+ indexState?: "empty" | "ready";
72
+ excerptLines?: number;
73
+ notes?: string[];
74
+ }): {
75
+ content: {
76
+ type: "text";
77
+ text: string;
78
+ }[];
79
+ details: {
80
+ query?: string;
81
+ mode?: string;
82
+ activationMs?: number;
83
+ backend?: string;
84
+ freshness?: "stale";
85
+ indexState?: "empty" | "ready";
86
+ excerptLines?: number;
87
+ notes?: string[];
88
+ ok: boolean;
89
+ command: string;
90
+ response: {
91
+ command: string;
92
+ tool: "asgrep";
93
+ schema_version: string;
94
+ ok: boolean;
95
+ version?: string;
96
+ machine_schema_version?: string;
97
+ };
98
+ };
99
+ };
100
+ export declare function errorDetails(cause: unknown, signal?: AbortSignal): {
101
+ code: string;
102
+ message: string;
103
+ details: Readonly<Record<string, unknown>>;
104
+ };
105
+ export declare function isFreshnessTimeout(cause: unknown, userSignal?: AbortSignal): boolean;
106
+ /** Leading or mid-query `in:path` scope used to bound a fresh-directory index. */
107
+ export declare function extractInPath(query: string): string | undefined;
108
+ export declare function failure(command: string, cause: unknown, signal?: AbortSignal): {
109
+ content: {
110
+ type: "text";
111
+ text: string;
112
+ }[];
113
+ details: {
114
+ ok: boolean;
115
+ command: string;
116
+ error: {
117
+ code: string;
118
+ message: string;
119
+ details: Readonly<Record<string, unknown>>;
120
+ };
121
+ };
122
+ };
123
+ export declare function report(onUpdate: Update | undefined, command: string, phase: "started" | "completed"): void;