dsh-plugin-codegraph-tree-sitter 0.5.3 → 0.6.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.
package/lib/index.js CHANGED
@@ -27,7 +27,7 @@
27
27
  * this package ships with.
28
28
  * @module dsh-plugin-codegraph-tree-sitter
29
29
  */
30
- import { readFileSync } from 'node:fs';
30
+ import { existsSync, readFileSync } from 'node:fs';
31
31
  import { readdir } from 'node:fs/promises';
32
32
  import { stat } from 'node:fs/promises';
33
33
  import { join, posix } from 'node:path';
@@ -146,6 +146,10 @@ export function apply(ctx, config) {
146
146
  // legitimately trigger another refresh. The map only keeps a burst of sessions opened at once from
147
147
  // queueing the same rebuild repeatedly; the freshness decision itself reads the status sidecar.
148
148
  const lastAutoRun = new Map();
149
+ // Roots whose background run is still executing. Kept apart from `lastAutoRun`, which is a time
150
+ // window: a first build can outlive the window, and two builds on one root would only race the same
151
+ // index files.
152
+ const autoIndexInFlight = new Set();
149
153
  // `agent/created` is typed by `@deepseek-ai/dsh-agent`'s module augmentation. Reading the event
150
154
  // through a local structural face keeps this package's host dependencies unchanged: it needs one
151
155
  // event name and one payload shape, not the whole agent package.
@@ -210,63 +214,88 @@ export function apply(ctx, config) {
210
214
  if (resolved.autoIndexOnSession) {
211
215
  // Registered inside this effect so the listener follows the plugin's lifetime: unloading the
212
216
  // plugin must stop new sessions from scheduling work this instance can no longer run.
213
- yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, payload));
217
+ yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, autoIndexInFlight, payload, ensureWatching));
214
218
  }
215
219
  yield ctx.codegraph.registerIndexer(indexer);
216
220
  }, 'codegraph-tree-sitter');
217
221
  }
218
- /** Stable job kind of an automatic session-start index, used for retention and diagnostics. */
219
- const AUTO_INDEX_JOB_KIND = 'codegraph-index';
222
+ /**
223
+ * Whether a directory is the harness's own home rather than a project checkout.
224
+ *
225
+ * `sessions/` and `profiles/` together are what makes it one: either alone is a plausible folder
226
+ * name inside an ordinary repository, and requiring both keeps a real workspace eligible. The check
227
+ * exists because a DSH home is by construction tens of thousands of files — indexing it can only
228
+ * ever hit the session-start size guard, so queueing the job produces a failure and nothing else.
229
+ * @param root - the absolute workspace path.
230
+ * @returns true when the directory holds the harness's own state.
231
+ */
232
+ function isHarnessHome(root) {
233
+ return existsSync(join(root, 'sessions')) && existsSync(join(root, 'profiles'));
234
+ }
220
235
  /**
221
236
  * Index a new session's workspace in the background — when nothing has indexed it yet, or when what
222
237
  * is there has gone stale.
223
238
  *
224
239
  * Runs synchronously inside `agent/created` dispatch, so it must never block or throw: it only decides
225
- * whether a build is warranted and hands one to the host's job registry. A synchronous build here
226
- * would stall agent creation for the length of a parse, which is why a composition without background
227
- * jobs does nothing instead.
240
+ * whether a build is warranted and starts one in the background. A synchronous build here would stall
241
+ * agent creation for the length of a parse.
242
+ *
243
+ * The run deliberately does **not** go through the host's job registry. A registered job reports its
244
+ * settlement back to the owning agent: the host injects a completion notice into the next step and,
245
+ * under its default `wakeup` delivery, opens a turn on an idle one. An automatic index nobody asked
246
+ * for must not appear in the transcript or start a reply of its own, so this runs as a plain
247
+ * background promise. The explicit `codegraph_index` tool still registers a job — there a caller is
248
+ * waiting for the id.
228
249
  *
229
250
  * Refreshing a stale index is what restarts the file watcher after a host restart: watching only
230
251
  * begins once a successful index has established a baseline, so a workspace whose index is minutes
231
252
  * old would otherwise stay frozen at whatever the last process left behind.
232
- * @param ctx - the plugin context, consulted for the optional job registry.
253
+ * @param ctx - the plugin context.
233
254
  * @param config - the resolved plugin configuration, for the staleness threshold.
234
255
  * @param lastAutoRun - when this instance last started an automatic index for each root.
256
+ * @param autoIndexInFlight - roots whose background run is still executing, so a slow build cannot be
257
+ * started twice by sessions that open past the freshness window.
235
258
  * @param payload - the `agent/created` dispatch payload.
259
+ * @param ensureWatching - starts file watching for a workspace that is already current.
236
260
  */
237
- function autoIndexOnSession(ctx, config, lastAutoRun, payload) {
261
+ function autoIndexOnSession(ctx, config, lastAutoRun, autoIndexInFlight, payload, ensureWatching) {
238
262
  try {
239
263
  const agent = payload.agent;
240
264
  const root = agent?.session?.header?.cwd;
241
265
  // No workspace means no root to claim: an agent created without a `cwd` has nothing to index.
242
266
  if (agent === undefined || !root)
243
267
  return;
268
+ // DSH's own home directory is not a code repository — `sessions/`, `attachments/`, the index
269
+ // databases and the installed plugins below it are a program's state. It is also tens of
270
+ // thousands of files, so every session opened there queued a job whose only possible outcome was
271
+ // the size guard failing. Skipping here keeps the job list honest: what is not a candidate never
272
+ // becomes a failure the user cannot act on.
273
+ if (isHarnessHome(root))
274
+ return;
244
275
  const now = Date.now();
245
276
  // A burst of sessions opened on one workspace must not queue the same rebuild repeatedly.
246
277
  const previous = lastAutoRun.get(root);
247
278
  if (previous !== undefined && now - previous < config.autoRefreshAfterMs)
248
279
  return;
280
+ // A build already running for this root is the one the window would have deduplicated had it been
281
+ // slow enough to straddle it; starting a second would only race the same index files.
282
+ if (autoIndexInFlight.has(root))
283
+ return;
249
284
  // A fresh sidecar means a recent run is already describing this workspace; an absent, unreadable
250
- // or old one means the graph is missing or behind. Checked before the job lookup so a composition
251
- // without background jobs never pays for the probe.
285
+ // or old one means the graph is missing or behind.
252
286
  const indexedAt = readStatusIndexedAt(root);
253
- if (indexedAt !== undefined && now - indexedAt < config.autoRefreshAfterMs)
254
- return;
255
- // `ctx.get` rather than an `inject` entry: `jobs` is a host composition choice, and requiring it in
256
- // `inject` would hide this whole indexer from a profile that runs no background jobs.
257
- const jobs = ctx.get('jobs');
258
- if (jobs === undefined)
287
+ if (indexedAt !== undefined && now - indexedAt < config.autoRefreshAfterMs) {
288
+ // The graph is current, so no build is warranted — but watching is process-local state and
289
+ // this is the first time this process has seen this workspace. Without this call, a restart
290
+ // followed by a session in an up-to-date workspace would leave it unwatched until the sidecar
291
+ // aged out, and every edit until then would silently not reach the graph.
292
+ ensureWatching(root);
259
293
  return;
260
- jobs.start({
261
- // The registry keys a job by its owning session id, not the agent object.
262
- owner: agent.id,
263
- kind: AUTO_INDEX_JOB_KIND,
264
- label: `codegraph auto index ${root}`,
265
- run: handle => runAutoIndexJob(ctx, root, config, handle),
266
- });
267
- // Marked only after the registry accepted the job, so a transient refusal leaves the workspace
268
- // eligible for the next session that opens in it.
294
+ }
269
295
  lastAutoRun.set(root, now);
296
+ autoIndexInFlight.add(root);
297
+ // Never rejects: a lost index is a degradation, not an error the session can act on.
298
+ void runAutomaticIndex(ctx, root, config).finally(() => autoIndexInFlight.delete(root));
270
299
  }
271
300
  catch (error) {
272
301
  // The host treats a throwing listener as vetoing publication. Losing an index is a degradation,
@@ -274,6 +303,33 @@ function autoIndexOnSession(ctx, config, lastAutoRun, payload) {
274
303
  console.error(`codegraph-tree-sitter: automatic indexing for a new session failed: ${String(error)}`);
275
304
  }
276
305
  }
306
+ /**
307
+ * Run one automatic index to completion, reporting a failure instead of propagating it.
308
+ *
309
+ * The size guard stays: parsing a workspace past `autoIndexMaxFiles` would exhaust the WASM heap and
310
+ * take the desktop down with it, and an automatic index is a convenience. Skipping it is a decision,
311
+ * not a failure, so it is logged once and the session continues untouched.
312
+ * @param ctx - the plugin context.
313
+ * @param root - absolute workspace path.
314
+ * @param config - the resolved plugin configuration.
315
+ */
316
+ async function runAutomaticIndex(ctx, root, config) {
317
+ const controller = new AbortController();
318
+ try {
319
+ const oversized = await exceedsAutoIndexLimit(root, config, controller.signal);
320
+ if (oversized !== undefined) {
321
+ console.error(`codegraph-tree-sitter: skipped automatic indexing of ${root}: it holds at least ${String(oversized)} ` +
322
+ `candidate source files, past the ${String(config.autoIndexMaxFiles)}-file session-start limit. ` +
323
+ 'Run codegraph_index yourself if you want a graph for it.');
324
+ return;
325
+ }
326
+ // A successful index is what starts file watching, inside `indexer.index`.
327
+ await ctx.codegraph.index(root, controller.signal);
328
+ }
329
+ catch (error) {
330
+ console.error(`codegraph-tree-sitter: automatic index for ${root} failed: ${String(error)}`);
331
+ }
332
+ }
277
333
  /**
278
334
  * How many candidate source files `root` holds, when that is past the session-start limit.
279
335
  *
@@ -339,43 +395,6 @@ function readStatusIndexedAt(root) {
339
395
  return undefined;
340
396
  }
341
397
  }
342
- /**
343
- * The producer body of one automatic indexing job.
344
- *
345
- * Both halves of the host's job contract are mandatory: `cancel` aborts the run, and `done` settles
346
- * even on failure — a rejected `done` breaks the registry's bookkeeping and leaves the job looking
347
- * stuck forever.
348
- * @param ctx - the plugin context.
349
- * @param root - the workspace to index.
350
- * @param handle - the job's producer face, for the job's live log.
351
- * @returns the job's cancellation hook and settlement promise.
352
- */
353
- function runAutoIndexJob(ctx, root, config, handle) {
354
- const controller = new AbortController();
355
- const done = (async () => {
356
- try {
357
- const oversized = await exceedsAutoIndexLimit(root, config, controller.signal);
358
- if (oversized !== undefined) {
359
- // Declining here is the whole point of the check: parsing this workspace would exhaust the
360
- // WASM heap and take the desktop down with it, and an automatic index is a convenience.
361
- handle.append(`Skipped automatic indexing of ${root}: it holds at least ${String(oversized)} candidate ` +
362
- `source files, past the ${String(config.autoIndexMaxFiles)}-file session-start limit. ` +
363
- `Run codegraph_index yourself if you want a graph for it.\n`);
364
- return { status: 'failed', detail: 'workspace too large for automatic indexing' };
365
- }
366
- const report = await ctx.codegraph.index(root, controller.signal);
367
- handle.append(`Indexed ${root}: ${report.filesIndexed} files, ${report.nodeCount} symbols, ` +
368
- `${report.edgeCount} relationships.\n`);
369
- return { status: 'completed' };
370
- }
371
- catch (error) {
372
- const message = String(error);
373
- handle.append(`codegraph auto index failed for ${root}: ${message}\n`);
374
- return { status: 'failed', detail: message };
375
- }
376
- })();
377
- return { cancel: () => controller.abort(), done };
378
- }
379
398
  /**
380
399
  * Run one watch-triggered reindex. Always a full rebuild in this version — `runIndex` has no notion of
381
400
  * a file subset — so the watcher's own collected paths are not consulted here.
@@ -18,9 +18,9 @@ import type { FileExtraction } from './extract.ts';
18
18
  export declare const RUST_LANGUAGES: readonly string[];
19
19
  /**
20
20
  * Files handed to one addon call. The addon parses a batch across every core it can, so a large batch
21
- * is what makes it fast — but the call itself blocks the event loop for its whole duration, so the
22
- * batch is bounded to keep one call short enough that a watcher rebuild or a host request is not
23
- * stalled behind it.
21
+ * is what makes it fast. The call runs on libuv's thread pool (see {@link RustEngine}), so a batch no
22
+ * longer stalls the event loop; the bound now keeps one promise's lifetime — and the file text it
23
+ * holds in flight — reasonable, instead of capping a blocking call.
24
24
  */
25
25
  export declare const RUST_BATCH_SIZE = 200;
26
26
  /** One file as the addon takes it. */
@@ -39,11 +39,16 @@ export interface RustExtractInput {
39
39
  export interface RustEngine {
40
40
  /**
41
41
  * Extract definitions, calls, imports, and heritage for a batch of files.
42
+ *
43
+ * Answers a promise because the addon's body runs on libuv's thread pool: a synchronous native call
44
+ * blocked the host's event loop for the whole batch, which is what froze the desktop when automatic
45
+ * indexing ran inside the renderer. Callers `await` it; an older addon that still returns a string
46
+ * keeps working, because `await` on a non-promise is the value itself.
42
47
  * @param inputs - the files to extract; inputs in an unsupported language are silently skipped.
43
48
  * @param rulesJson - the `LanguageSpec` table, as JSON.
44
- * @returns JSON of `{ results: [{ path, extraction }], supported: string[] }`.
49
+ * @returns a promise of JSON `{ results: [{ path, extraction }], supported: string[] }`.
45
50
  */
46
- extractWorkspace(inputs: RustExtractInput[], rulesJson: string): string;
51
+ extractWorkspace(inputs: RustExtractInput[], rulesJson: string): Promise<string>;
47
52
  }
48
53
  /** Options for {@link loadRustEngine}. */
49
54
  export interface LoadRustEngineOptions {
@@ -53,14 +58,47 @@ export interface LoadRustEngineOptions {
53
58
  */
54
59
  readonly modulePath?: string;
55
60
  }
61
+ /**
62
+ * Where the native package for this platform lives, when npm installed one.
63
+ *
64
+ * The addon ships as one optional dependency per platform (`optionalDependencies` in this package's
65
+ * manifest), so a `pnpm add` pulls exactly one binary — the one the running machine can `dlopen` —
66
+ * instead of every platform's. npm's `os`/`cpu` fields do the selection; a platform this project does
67
+ * not build for simply never installs one and the walk falls back to JS.
68
+ * @param platform - `process.platform`.
69
+ * @param arch - `process.arch`.
70
+ * @returns the package name, or `undefined` for a platform with no published build.
71
+ */
72
+ export declare function nativePackageName(platform: string, arch: string): string | undefined;
73
+ /**
74
+ * Resolve this platform's optional native package to its `.node` entry, if it is installed.
75
+ *
76
+ * `require.resolve` is the only thing that knows where a dependency actually landed — pnpm's store,
77
+ * npm's flat tree, a hoisted profile — so the path is asked for rather than constructed. A missing
78
+ * package, or one whose `main` does not resolve, is `undefined`: that is the documented "no addon"
79
+ * answer, not an error.
80
+ * @param platform - `process.platform`.
81
+ * @param arch - `process.arch`.
82
+ * @param resolve - module resolution, injectable so a test does not depend on what this machine has
83
+ * installed.
84
+ * @returns the absolute path to the addon, or `undefined`.
85
+ */
86
+ export declare function nativePackageModulePath(platform: string, arch: string, resolve?: (name: string) => string): string | undefined;
56
87
  /**
57
88
  * Every place the addon may live, most specific first.
58
89
  *
59
- * Two layouts matter: a package that ships the binary puts it in `artifacts/` beside `lib/` (the
60
- * published shape, and what a profile install gets when someone copies it in), while a repository
61
- * checkout builds it under `crates/indexer-rust/artifacts/`, three directories up from both `src/`
62
- * and `lib/` since those sit at the same depth. Trying both means one module works whether it was
63
- * installed or checked out.
90
+ * Three layouts matter: a package that ships the binary puts it in `artifacts/` beside `lib/` (the
91
+ * shape an older hand-copied install has), a repository checkout builds it under
92
+ * `crates/indexer-rust/artifacts/` (three directories up from both `src/` and `lib/`, which sit at the
93
+ * same depth), and an npm install carries it in the platform's optional dependency. Trying all three
94
+ * means one module works whether it was installed, copied in, or checked out.
95
+ * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
96
+ * @param fromPackage - this platform's optional-dependency path, or `undefined` when there is none.
97
+ * @returns absolute paths to try, in order.
98
+ */
99
+ export declare function modulePathsFor(base: string, fromPackage: string | undefined): string[];
100
+ /**
101
+ * The paths production should try, with this platform's optional package worked out for it.
64
102
  * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
65
103
  * @returns absolute paths to try, in order.
66
104
  */
@@ -89,7 +127,7 @@ export declare function loadRustEngine(options?: LoadRustEngineOptions): RustEng
89
127
  * @returns one extraction per path the addon answered for, or `undefined` when no answer can be
90
128
  * trusted at all — no addon, a thrown call, or JSON that is not the documented shape.
91
129
  */
92
- export declare function extractWithRust(files: readonly RustExtractInput[], engine: RustEngine | undefined): Map<string, FileExtraction> | undefined;
130
+ export declare function extractWithRust(files: readonly RustExtractInput[], engine: RustEngine | undefined): Promise<Map<string, FileExtraction> | undefined>;
93
131
  /**
94
132
  * Install a stand-in for the addon the walk resolves, or restore the real one.
95
133
  *
@@ -20,30 +20,93 @@ import { LANGUAGE_TABLE } from "./languages.js";
20
20
  export const RUST_LANGUAGES = ['typescript', 'tsx', 'javascript', 'jsx'];
21
21
  /**
22
22
  * Files handed to one addon call. The addon parses a batch across every core it can, so a large batch
23
- * is what makes it fast — but the call itself blocks the event loop for its whole duration, so the
24
- * batch is bounded to keep one call short enough that a watcher rebuild or a host request is not
25
- * stalled behind it.
23
+ * is what makes it fast. The call runs on libuv's thread pool (see {@link RustEngine}), so a batch no
24
+ * longer stalls the event loop; the bound now keeps one promise's lifetime — and the file text it
25
+ * holds in flight — reasonable, instead of capping a blocking call.
26
26
  */
27
27
  export const RUST_BATCH_SIZE = 200;
28
28
  const require = createRequire(import.meta.url);
29
+ /**
30
+ * Where the native package for this platform lives, when npm installed one.
31
+ *
32
+ * The addon ships as one optional dependency per platform (`optionalDependencies` in this package's
33
+ * manifest), so a `pnpm add` pulls exactly one binary — the one the running machine can `dlopen` —
34
+ * instead of every platform's. npm's `os`/`cpu` fields do the selection; a platform this project does
35
+ * not build for simply never installs one and the walk falls back to JS.
36
+ * @param platform - `process.platform`.
37
+ * @param arch - `process.arch`.
38
+ * @returns the package name, or `undefined` for a platform with no published build.
39
+ */
40
+ export function nativePackageName(platform, arch) {
41
+ const suffix = NATIVE_PACKAGES[`${platform}-${arch}`];
42
+ return suffix === undefined ? undefined : `dsh-plugin-codegraph-indexer-${suffix}`;
43
+ }
44
+ /**
45
+ * The suffix each published platform package carries, keyed by `platform-arch`.
46
+ *
47
+ * A suffix is not mechanical: the Linux builds are `-gnu` because a musl one would have to be a
48
+ * separate artifact, while Windows spells its ABI out. Keeping the table explicit is what stops this
49
+ * from silently computing a package name nobody publishes.
50
+ */
51
+ const NATIVE_PACKAGES = {
52
+ 'darwin-arm64': 'darwin-arm64',
53
+ 'darwin-x64': 'darwin-x64',
54
+ 'linux-x64': 'linux-x64-gnu',
55
+ 'linux-arm64': 'linux-arm64-gnu',
56
+ 'win32-x64': 'win32-x64-msvc',
57
+ };
58
+ /**
59
+ * Resolve this platform's optional native package to its `.node` entry, if it is installed.
60
+ *
61
+ * `require.resolve` is the only thing that knows where a dependency actually landed — pnpm's store,
62
+ * npm's flat tree, a hoisted profile — so the path is asked for rather than constructed. A missing
63
+ * package, or one whose `main` does not resolve, is `undefined`: that is the documented "no addon"
64
+ * answer, not an error.
65
+ * @param platform - `process.platform`.
66
+ * @param arch - `process.arch`.
67
+ * @param resolve - module resolution, injectable so a test does not depend on what this machine has
68
+ * installed.
69
+ * @returns the absolute path to the addon, or `undefined`.
70
+ */
71
+ export function nativePackageModulePath(platform, arch, resolve = require.resolve) {
72
+ const name = nativePackageName(platform, arch);
73
+ if (name === undefined)
74
+ return undefined;
75
+ try {
76
+ return resolve(name);
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
29
82
  /**
30
83
  * Every place the addon may live, most specific first.
31
84
  *
32
- * Two layouts matter: a package that ships the binary puts it in `artifacts/` beside `lib/` (the
33
- * published shape, and what a profile install gets when someone copies it in), while a repository
34
- * checkout builds it under `crates/indexer-rust/artifacts/`, three directories up from both `src/`
35
- * and `lib/` since those sit at the same depth. Trying both means one module works whether it was
36
- * installed or checked out.
85
+ * Three layouts matter: a package that ships the binary puts it in `artifacts/` beside `lib/` (the
86
+ * shape an older hand-copied install has), a repository checkout builds it under
87
+ * `crates/indexer-rust/artifacts/` (three directories up from both `src/` and `lib/`, which sit at the
88
+ * same depth), and an npm install carries it in the platform's optional dependency. Trying all three
89
+ * means one module works whether it was installed, copied in, or checked out.
37
90
  * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
91
+ * @param fromPackage - this platform's optional-dependency path, or `undefined` when there is none.
38
92
  * @returns absolute paths to try, in order.
39
93
  */
40
- export function candidateModulePaths(base = import.meta.url) {
94
+ export function modulePathsFor(base, fromPackage) {
41
95
  const artifact = `codegraph-indexer.${process.platform}-${process.arch}.node`;
42
96
  return [
43
97
  fileURLToPath(new URL(`../artifacts/${artifact}`, base)),
44
98
  fileURLToPath(new URL(`../../../crates/indexer-rust/artifacts/${artifact}`, base)),
99
+ ...fromPackage === undefined ? [] : [fromPackage],
45
100
  ];
46
101
  }
102
+ /**
103
+ * The paths production should try, with this platform's optional package worked out for it.
104
+ * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
105
+ * @returns absolute paths to try, in order.
106
+ */
107
+ export function candidateModulePaths(base = import.meta.url) {
108
+ return modulePathsFor(base, nativePackageModulePath(process.platform, process.arch));
109
+ }
47
110
  /** Addons already loaded, keyed by path, so a test's stand-in never shadows the real artifact. */
48
111
  const loaded = new Map();
49
112
  /**
@@ -149,7 +212,7 @@ function rustRulesJson() {
149
212
  * @returns one extraction per path the addon answered for, or `undefined` when no answer can be
150
213
  * trusted at all — no addon, a thrown call, or JSON that is not the documented shape.
151
214
  */
152
- export function extractWithRust(files, engine) {
215
+ export async function extractWithRust(files, engine) {
153
216
  const supported = files.filter(file => RUST_LANGUAGES.includes(file.language));
154
217
  if (supported.length === 0)
155
218
  return new Map();
@@ -159,7 +222,10 @@ export function extractWithRust(files, engine) {
159
222
  try {
160
223
  for (let start = 0; start < supported.length; start += RUST_BATCH_SIZE) {
161
224
  const batch = supported.slice(start, start + RUST_BATCH_SIZE);
162
- const answer = JSON.parse(engine.extractWorkspace(batch, rustRulesJson()));
225
+ // Awaited one batch at a time: the addon already parses a batch across every core, so
226
+ // overlapping batches would only compete for the same threads while multiplying the text held
227
+ // in flight.
228
+ const answer = JSON.parse(await engine.extractWorkspace(batch, rustRulesJson()));
163
229
  if (!isRecord(answer) || !Array.isArray(answer.results))
164
230
  return undefined;
165
231
  for (const entry of answer.results) {
package/lib/schema.js CHANGED
@@ -144,7 +144,12 @@ export async function writeGraph(databasePath, input) {
144
144
  */
145
145
  async function writeStatusFile(directory, status) {
146
146
  const target = join(directory, basename(INDEX_STATUS_RELATIVE_PATH));
147
- const temp = `${target}.tmp`;
147
+ // Unique like the graph's own staging name, and for the same reason: two runs on one workspace are
148
+ // allowed to overlap (a session-start build and an explicit `codegraph_index`, or a watcher rebuild),
149
+ // and a shared fixed-name `.tmp` makes the loser's `rename` fail with ENOENT after the winner moved
150
+ // it — which turned a harmless duplicate run into a failed tool call. Last rename wins; both graphs
151
+ // are complete.
152
+ const temp = `${target}.tmp-${process.pid}-${randomUUID()}`;
148
153
  await writeFile(temp, JSON.stringify(status));
149
154
  await rename(temp, target);
150
155
  }
package/lib/walk.js CHANGED
@@ -223,7 +223,7 @@ async function extractPending(pending, config, signal) {
223
223
  // those itself (`RUST_LANGUAGES`), so the walk never keeps a second copy of that list in sync.
224
224
  const rusted = engine === undefined
225
225
  ? new Map()
226
- : extractWithRust(pending.map(toRustInput), engine) ?? new Map();
226
+ : await extractWithRust(pending.map(toRustInput), engine) ?? new Map();
227
227
  const extracted = new Array(pending.length);
228
228
  const fallback = [];
229
229
  for (const [index, file] of pending.entries()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-codegraph-tree-sitter",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Self-built code-graph indexer for DeepSeek Harness — parses a workspace with web-tree-sitter and writes schema version 4 to .codegraph/codegraph.db, the same on-disk format the codegraph CLI writes, so no external indexer is required",
5
5
  "keywords": [
6
6
  "dsh",
@@ -51,13 +51,20 @@
51
51
  "@deepseek-ai/schemastery": "^3.18.1",
52
52
  "tree-sitter-wasms": "^0.1.13",
53
53
  "web-tree-sitter": "^0.25.10",
54
- "dsh-plugin-codegraph-service": "^0.5.3"
54
+ "dsh-plugin-codegraph-service": "^0.6.1"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",
58
58
  "@deepseek-ai/dsh-fs": ">=0.1.0-rc.6",
59
59
  "@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6"
60
60
  },
61
+ "optionalDependencies": {
62
+ "dsh-plugin-codegraph-indexer-darwin-arm64": "0.6.1",
63
+ "dsh-plugin-codegraph-indexer-darwin-x64": "0.6.1",
64
+ "dsh-plugin-codegraph-indexer-linux-x64-gnu": "0.6.1",
65
+ "dsh-plugin-codegraph-indexer-linux-arm64-gnu": "0.6.1",
66
+ "dsh-plugin-codegraph-indexer-win32-x64-msvc": "0.6.1"
67
+ },
61
68
  "devDependencies": {
62
69
  "@deepseek-ai/cordis": "^4.0.1",
63
70
  "@deepseek-ai/dsh-invariants": "0.1.0-rc.6"