@takara-ai/miru-code 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Find code by meaning, not grep. Miru returns the best **chunks** (path, lines, s
10
10
 
11
11
  Miru sends **file contents** to the [Takara inference API](https://takara.ai) when building an index and when embedding search queries. Chunks from your repo are transmitted over HTTPS to generate embeddings. API usage may incur cost depending on your Takara plan.
12
12
 
13
- If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. Use `MIRU_WORKSPACE_ROOT` to restrict MCP indexing to a single workspace directory.
13
+ If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. `MIRU_WORKSPACE_ROOT` is an opt-in boundary for MCP local `repo` paths only, and restricts indexing to a single workspace directory when set.
14
14
 
15
15
  ## Install
16
16
 
@@ -231,7 +231,7 @@ const results = await index.search({ query: "BM25 tokenize", topK: 10 });
231
231
  | `TAKARA_API_KEY` | Required |
232
232
  | `MIRU_OPENAI_BASE_URL` | Default `https://infer.takara.ai/v1` |
233
233
  | `MIRU_OPENAI_EMBEDDING_MODEL` | Default `ds1-miru-int8` |
234
- | `MIRU_WORKSPACE_ROOT` | Restrict MCP local `repo` paths to this directory |
234
+ | `MIRU_WORKSPACE_ROOT` | Optional: restrict MCP local `repo` paths to this directory |
235
235
  | `MIRU_MAX_INDEX_FILES` | Cap files indexed per operation |
236
236
  | `MIRU_ALLOW_HTTP_GIT` | Set `1` to allow plain `http://` git clones |
237
237
  | `MIRU_MCP_WATCH` | Set `0` to disable MCP file watch |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -44,6 +44,8 @@ import {
44
44
  } from "./utils.ts";
45
45
  import { maybeNotifyUpdate, miruVersion } from "./version.ts";
46
46
 
47
+ process.title = "miru";
48
+
47
49
  await loadEnvFiles();
48
50
  normalizeTakaraApiKeyEnv();
49
51
  await loadStoredCredentials();
@@ -107,13 +107,13 @@ export async function mergeJsonMember(
107
107
  if (JSON.stringify(section[memberKey]) === JSON.stringify(value)) {
108
108
  return "unchanged";
109
109
  }
110
-
111
- section[memberKey] = value;
112
- parsed[sectionKey] = section;
113
-
114
- const next = `${JSON.stringify(parsed, null, 2)}\n`;
115
-
116
- await Bun.write(path, next);
110
+ const next = upsertJsonMemberText(text, sectionKey, memberKey, value);
111
+ await Bun.write(
112
+ path,
113
+ ensureTrailingNewline(
114
+ next ?? JSON.stringify(withMergedMember(parsed, sectionKey, memberKey, value), null, 2),
115
+ ),
116
+ );
117
117
  return existed ? "updated" : "created";
118
118
  }
119
119
 
@@ -136,26 +136,276 @@ export async function removeJsonMember(
136
136
  if (section === "error" || !(memberKey in section)) {
137
137
  return "not-found";
138
138
  }
139
+ const nextText = removeJsonMemberText(text, sectionKey, memberKey);
140
+ const nextObject = withRemovedMember(parsed, sectionKey, memberKey);
141
+ const next = nextText ?? JSON.stringify(nextObject, null, 2);
142
+ if (ensureTrailingNewline(next) === ensureTrailingNewline(text)) {
143
+ return "not-found";
144
+ }
145
+ const nextParsed = parseJsonObject(next);
146
+ if (!nextParsed || typeof nextParsed !== "object" || Object.keys(nextParsed).length === 0) {
147
+ await unlink(path);
148
+ return "removed";
149
+ }
150
+ await Bun.write(path, ensureTrailingNewline(next));
151
+ return "removed";
152
+ }
153
+
154
+ function withMergedMember(
155
+ root: Record<string, unknown>,
156
+ sectionKey: string,
157
+ memberKey: string,
158
+ value: Record<string, unknown>,
159
+ ): Record<string, unknown> {
160
+ const next = { ...root };
161
+ const section =
162
+ next[sectionKey] && typeof next[sectionKey] === "object" && !Array.isArray(next[sectionKey])
163
+ ? { ...(next[sectionKey] as Record<string, unknown>) }
164
+ : {};
165
+ section[memberKey] = value;
166
+ next[sectionKey] = section;
167
+ return next;
168
+ }
139
169
 
170
+ function withRemovedMember(
171
+ root: Record<string, unknown>,
172
+ sectionKey: string,
173
+ memberKey: string,
174
+ ): Record<string, unknown> {
175
+ const next = { ...root };
176
+ const section =
177
+ next[sectionKey] && typeof next[sectionKey] === "object" && !Array.isArray(next[sectionKey])
178
+ ? { ...(next[sectionKey] as Record<string, unknown>) }
179
+ : null;
180
+ if (!section || !(memberKey in section)) {
181
+ return next;
182
+ }
140
183
  delete section[memberKey];
141
184
  if (Object.keys(section).length === 0) {
142
- delete parsed[sectionKey];
185
+ delete next[sectionKey];
143
186
  } else {
144
- parsed[sectionKey] = section;
187
+ next[sectionKey] = section;
145
188
  }
189
+ return next;
190
+ }
146
191
 
147
- const next = `${JSON.stringify(parsed, null, 2)}\n`;
148
- if (next === `${text.endsWith("\n") ? text : `${text}\n`}`) {
149
- return "not-found";
192
+ function upsertJsonMemberText(
193
+ text: string,
194
+ sectionKey: string,
195
+ memberKey: string,
196
+ value: Record<string, unknown>,
197
+ ): string | null {
198
+ if (!hasJsoncSyntax(text)) {
199
+ return null;
200
+ }
201
+ const sectionRange = findSectionRange(text, sectionKey);
202
+ if (!sectionRange) {
203
+ return null;
204
+ }
205
+ const { openBrace, closeBrace, indent } = sectionRange;
206
+ const sectionBody = text.slice(openBrace + 1, closeBrace);
207
+ const memberMatch = new RegExp(`"(${escapeRegExp(memberKey)})"\\s*:`).exec(sectionBody);
208
+ const valueText = JSON.stringify(value);
209
+ if (memberMatch) {
210
+ const memberKeyStart = openBrace + 1 + memberMatch.index;
211
+ const colonIndex = text.indexOf(":", memberKeyStart);
212
+ const valueStart = skipWhitespace(text, colonIndex + 1);
213
+ const valueEnd = parseJsonValueEnd(text, valueStart);
214
+ if (valueEnd <= valueStart) {
215
+ return null;
216
+ }
217
+ return `${text.slice(0, valueStart)} ${valueText}${text.slice(valueEnd)}`;
150
218
  }
151
219
 
152
- if (Object.keys(parsed).length === 0) {
153
- await unlink(path);
154
- return "removed";
220
+ const memberIndent = `${indent} `;
221
+ const insertion = `${sectionBody.trim().length === 0 ? "" : ","}\n${memberIndent}"${memberKey}": ${valueText}\n${indent}`;
222
+ return `${text.slice(0, closeBrace)}${insertion}${text.slice(closeBrace)}`;
223
+ }
224
+
225
+ function removeJsonMemberText(text: string, sectionKey: string, memberKey: string): string | null {
226
+ if (!hasJsoncSyntax(text)) {
227
+ return null;
228
+ }
229
+ const sectionRange = findSectionRange(text, sectionKey);
230
+ if (!sectionRange) {
231
+ return null;
155
232
  }
233
+ const { openBrace, closeBrace } = sectionRange;
234
+ const sectionBody = text.slice(openBrace + 1, closeBrace);
235
+ const memberMatch = new RegExp(`"(${escapeRegExp(memberKey)})"\\s*:`).exec(sectionBody);
236
+ if (!memberMatch) {
237
+ return null;
238
+ }
239
+ const memberStart = openBrace + 1 + memberMatch.index;
240
+ const colonIndex = text.indexOf(":", memberStart);
241
+ const valueStart = skipWhitespace(text, colonIndex + 1);
242
+ const valueEnd = parseJsonValueEnd(text, valueStart);
243
+ let removeStart = memberStart;
244
+ let removeEnd = valueEnd;
245
+
246
+ const trailing = skipWhitespace(text, valueEnd);
247
+ if (text[trailing] === ",") {
248
+ removeEnd = trailing + 1;
249
+ } else {
250
+ const leadingComma = findLeadingComma(text, memberStart, openBrace + 1);
251
+ if (leadingComma >= 0) {
252
+ removeStart = leadingComma;
253
+ }
254
+ }
255
+ return `${text.slice(0, removeStart)}${text.slice(removeEnd)}`;
256
+ }
156
257
 
157
- await Bun.write(path, next);
158
- return "removed";
258
+ function hasJsoncSyntax(text: string): boolean {
259
+ return text.includes("//") || text.includes("/*");
260
+ }
261
+
262
+ function findSectionRange(
263
+ text: string,
264
+ sectionKey: string,
265
+ ): { openBrace: number; closeBrace: number; indent: string } | null {
266
+ const keyMatch = new RegExp(`(^|\\n)([ \\t]*)"${escapeRegExp(sectionKey)}"\\s*:\\s*\\{`).exec(
267
+ text,
268
+ );
269
+ if (!keyMatch) {
270
+ return null;
271
+ }
272
+ const openBrace = text.indexOf("{", keyMatch.index);
273
+ const closeBrace = findMatchingBrace(text, openBrace);
274
+ if (openBrace < 0 || closeBrace < 0) {
275
+ return null;
276
+ }
277
+ return { openBrace, closeBrace, indent: keyMatch[2] ?? "" };
278
+ }
279
+
280
+ function findMatchingBrace(text: string, openIndex: number): number {
281
+ let depth = 0;
282
+ let inString = false;
283
+ let quote = "";
284
+ let inLineComment = false;
285
+ let inBlockComment = false;
286
+ for (let i = openIndex; i < text.length; i++) {
287
+ const ch = text[i] ?? "";
288
+ const next = text[i + 1] ?? "";
289
+ if (inLineComment) {
290
+ if (ch === "\n") inLineComment = false;
291
+ continue;
292
+ }
293
+ if (inBlockComment) {
294
+ if (ch === "*" && next === "/") {
295
+ inBlockComment = false;
296
+ i++;
297
+ }
298
+ continue;
299
+ }
300
+ if (inString) {
301
+ if (ch === "\\") {
302
+ i++;
303
+ continue;
304
+ }
305
+ if (ch === quote) {
306
+ inString = false;
307
+ }
308
+ continue;
309
+ }
310
+ if (ch === "/" && next === "/") {
311
+ inLineComment = true;
312
+ i++;
313
+ continue;
314
+ }
315
+ if (ch === "/" && next === "*") {
316
+ inBlockComment = true;
317
+ i++;
318
+ continue;
319
+ }
320
+ if (ch === '"' || ch === "'") {
321
+ inString = true;
322
+ quote = ch;
323
+ continue;
324
+ }
325
+ if (ch === "{") depth++;
326
+ if (ch === "}") {
327
+ depth--;
328
+ if (depth === 0) return i;
329
+ }
330
+ }
331
+ return -1;
332
+ }
333
+
334
+ function parseJsonValueEnd(text: string, start: number): number {
335
+ const first = text[start];
336
+ if (!first) return start;
337
+ if (first === "{") return findMatchingBrace(text, start) + 1;
338
+ if (first === "[") return findMatchingBracket(text, start) + 1;
339
+ if (first === '"' || first === "'") return findStringEnd(text, start) + 1;
340
+ let i = start;
341
+ while (i < text.length && ![",", "}", "]", "\n"].includes(text[i] ?? "")) {
342
+ i++;
343
+ }
344
+ return i;
345
+ }
346
+
347
+ function findMatchingBracket(text: string, openIndex: number): number {
348
+ let depth = 0;
349
+ let inString = false;
350
+ let quote = "";
351
+ for (let i = openIndex; i < text.length; i++) {
352
+ const ch = text[i] ?? "";
353
+ if (inString) {
354
+ if (ch === "\\") {
355
+ i++;
356
+ continue;
357
+ }
358
+ if (ch === quote) inString = false;
359
+ continue;
360
+ }
361
+ if (ch === '"' || ch === "'") {
362
+ inString = true;
363
+ quote = ch;
364
+ continue;
365
+ }
366
+ if (ch === "[") depth++;
367
+ if (ch === "]") {
368
+ depth--;
369
+ if (depth === 0) return i;
370
+ }
371
+ }
372
+ return -1;
373
+ }
374
+
375
+ function findStringEnd(text: string, start: number): number {
376
+ const quote = text[start] ?? '"';
377
+ for (let i = start + 1; i < text.length; i++) {
378
+ const ch = text[i] ?? "";
379
+ if (ch === "\\") {
380
+ i++;
381
+ continue;
382
+ }
383
+ if (ch === quote) return i;
384
+ }
385
+ return start;
386
+ }
387
+
388
+ function skipWhitespace(text: string, index: number): number {
389
+ let i = index;
390
+ while (i < text.length && /\s/.test(text[i] ?? "")) i++;
391
+ return i;
392
+ }
393
+
394
+ function findLeadingComma(text: string, index: number, min: number): number {
395
+ for (let i = index - 1; i >= min; i--) {
396
+ const ch = text[i] ?? "";
397
+ if (ch === ",") return i;
398
+ if (!/\s/.test(ch)) break;
399
+ }
400
+ return -1;
401
+ }
402
+
403
+ function escapeRegExp(value: string): string {
404
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
405
+ }
406
+
407
+ function ensureTrailingNewline(text: string): string {
408
+ return text.endsWith("\n") ? text : `${text}\n`;
159
409
  }
160
410
 
161
411
  export async function replaceOrAppendMarked(path: string, content: string): Promise<InstallAction> {
@@ -5,6 +5,8 @@ import { stripJsonComments } from "../config.ts";
5
5
 
6
6
  const HOOK_GUARD_MARKER = "hook-guard";
7
7
  const MIRU_HOOK_MARKER = "miru-search";
8
+ const VSCODE_OWNERS_KEY = "miruOwners";
9
+ const SHARED_VSCODE_HOOK_OWNERS = ["copilot", "vscode", "visualstudio"] as const;
8
10
 
9
11
  const MATCHERS = {
10
12
  cursor: "Grep|Glob|Shell|SemanticSearch",
@@ -304,37 +306,127 @@ export async function removeGeminiHooks(path: string): Promise<InstallAction> {
304
306
  return removeHooksSection(path, ["BeforeTool"], (entry) => hookCommandMatches(entry.command));
305
307
  }
306
308
 
307
- export async function mergeVscodeHooks(path: string): Promise<InstallAction> {
309
+ export async function mergeVscodeHooks(path: string, owner?: string): Promise<InstallAction> {
308
310
  const command = resolveHookCommand();
309
- const content = {
310
- hooks: {
311
- PreToolUse: [
312
- {
313
- type: "command",
314
- command,
315
- matcher: MATCHERS.claude,
316
- statusMessage: "Miru search policy",
317
- },
318
- ],
319
- },
320
- };
321
311
  const existed = await Bun.file(path).exists();
322
- if (existed) {
323
- const current = await Bun.file(path).text();
324
- const next = `${JSON.stringify(content, null, 2)}\n`;
325
- if (current === next) {
326
- return "unchanged";
327
- }
312
+ const text = existed ? await Bun.file(path).text() : "";
313
+ const parsed = parseJsonObject(text);
314
+ if (parsed === "error") {
315
+ return "error";
328
316
  }
329
- await Bun.write(path, `${JSON.stringify(content, null, 2)}\n`);
317
+
318
+ const hooksRoot =
319
+ parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks)
320
+ ? (parsed.hooks as Record<string, unknown>)
321
+ : {};
322
+
323
+ const nextEntry = {
324
+ type: "command",
325
+ command,
326
+ matcher: MATCHERS.claude,
327
+ statusMessage: "Miru search policy",
328
+ };
329
+
330
+ const existingPreToolUse = Array.isArray(hooksRoot.PreToolUse)
331
+ ? (hooksRoot.PreToolUse as unknown[])
332
+ : [];
333
+ const mergedPreToolUse =
334
+ existingPreToolUse.length > 0 &&
335
+ existingPreToolUse.some((entry) =>
336
+ hookCommandMatches((entry as Record<string, unknown>).command),
337
+ )
338
+ ? existingPreToolUse.map((entry) => {
339
+ const hookEntry = entry as Record<string, unknown>;
340
+ if (!hookCommandMatches(hookEntry.command)) {
341
+ return hookEntry;
342
+ }
343
+ return nextEntry;
344
+ })
345
+ : [...existingPreToolUse, nextEntry];
346
+
347
+ const currentOwners = Array.isArray(parsed[VSCODE_OWNERS_KEY])
348
+ ? (parsed[VSCODE_OWNERS_KEY] as unknown[]).filter(
349
+ (item): item is string =>
350
+ typeof item === "string" &&
351
+ SHARED_VSCODE_HOOK_OWNERS.includes(item as (typeof SHARED_VSCODE_HOOK_OWNERS)[number]),
352
+ )
353
+ : [];
354
+ const nextOwners = owner
355
+ ? Array.from(
356
+ new Set([
357
+ ...currentOwners,
358
+ ...(SHARED_VSCODE_HOOK_OWNERS.includes(
359
+ owner as (typeof SHARED_VSCODE_HOOK_OWNERS)[number],
360
+ )
361
+ ? [owner]
362
+ : []),
363
+ ]),
364
+ )
365
+ : currentOwners;
366
+
367
+ const next = {
368
+ ...parsed,
369
+ hooks: { ...hooksRoot, PreToolUse: mergedPreToolUse },
370
+ ...(nextOwners.length > 0 ? { [VSCODE_OWNERS_KEY]: nextOwners } : {}),
371
+ };
372
+ const nextJson = `${JSON.stringify(next, null, 2)}\n`;
373
+ if (nextJson === `${text.endsWith("\n") ? text : `${text}\n`}`) {
374
+ return "unchanged";
375
+ }
376
+ await Bun.write(path, nextJson);
330
377
  return existed ? "updated" : "created";
331
378
  }
332
379
 
333
- export async function removeVscodeHooks(path: string): Promise<InstallAction> {
380
+ export async function removeVscodeHooks(path: string, owner?: string): Promise<InstallAction> {
334
381
  if (!(await Bun.file(path).exists())) {
335
382
  return "not-found";
336
383
  }
337
384
  const text = await Bun.file(path).text();
385
+ const parsed = parseJsonObject(text);
386
+ if (parsed === "error") {
387
+ return "error";
388
+ }
389
+
390
+ const owners = Array.isArray(parsed[VSCODE_OWNERS_KEY])
391
+ ? (parsed[VSCODE_OWNERS_KEY] as unknown[]).filter(
392
+ (item): item is string => typeof item === "string",
393
+ )
394
+ : null;
395
+ if (owners && owner) {
396
+ const remainingOwners = owners.filter((value) => value !== owner);
397
+ if (remainingOwners.length > 0) {
398
+ parsed[VSCODE_OWNERS_KEY] = remainingOwners;
399
+ await Bun.write(path, `${JSON.stringify(parsed, null, 2)}\n`);
400
+ return "updated";
401
+ }
402
+ delete parsed[VSCODE_OWNERS_KEY];
403
+ const hooksRoot =
404
+ parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks)
405
+ ? (parsed.hooks as Record<string, unknown>)
406
+ : {};
407
+ const filtered = Array.isArray(hooksRoot.PreToolUse)
408
+ ? (hooksRoot.PreToolUse as Record<string, unknown>[]).filter(
409
+ (entry) => !hookCommandMatches(entry.command),
410
+ )
411
+ : [];
412
+ if (filtered.length === 0) {
413
+ delete hooksRoot.PreToolUse;
414
+ } else {
415
+ hooksRoot.PreToolUse = filtered;
416
+ }
417
+ if (Object.keys(hooksRoot).length === 0) {
418
+ delete parsed.hooks;
419
+ } else {
420
+ parsed.hooks = hooksRoot;
421
+ }
422
+ if (isEmptyHookConfig(parsed)) {
423
+ await unlink(path);
424
+ return "removed";
425
+ }
426
+ await Bun.write(path, `${JSON.stringify(parsed, null, 2)}\n`);
427
+ return "removed";
428
+ }
429
+
338
430
  if (!text.includes(HOOK_GUARD_MARKER) && !text.includes(MIRU_HOOK_MARKER)) {
339
431
  return "not-found";
340
432
  }
@@ -413,7 +505,11 @@ export async function removeOpenCodePlugin(pluginPath: string): Promise<InstallA
413
505
  return "removed";
414
506
  }
415
507
 
416
- export async function mergeHooks(format: HooksFormat, path: string): Promise<InstallAction> {
508
+ export async function mergeHooks(
509
+ format: HooksFormat,
510
+ path: string,
511
+ owner?: string,
512
+ ): Promise<InstallAction> {
417
513
  switch (format) {
418
514
  case "cursor":
419
515
  return mergeCursorHooks(path);
@@ -422,7 +518,7 @@ export async function mergeHooks(format: HooksFormat, path: string): Promise<Ins
422
518
  case "gemini":
423
519
  return mergeGeminiHooks(path);
424
520
  case "vscode":
425
- return mergeVscodeHooks(path);
521
+ return mergeVscodeHooks(path, owner);
426
522
  case "kiro":
427
523
  return mergeKiroHooks(path);
428
524
  case "windsurf":
@@ -436,7 +532,11 @@ export async function mergeHooks(format: HooksFormat, path: string): Promise<Ins
436
532
  }
437
533
  }
438
534
 
439
- export async function removeHooks(format: HooksFormat, path: string): Promise<InstallAction> {
535
+ export async function removeHooks(
536
+ format: HooksFormat,
537
+ path: string,
538
+ owner?: string,
539
+ ): Promise<InstallAction> {
440
540
  switch (format) {
441
541
  case "cursor":
442
542
  return removeCursorHooks(path);
@@ -445,7 +545,7 @@ export async function removeHooks(format: HooksFormat, path: string): Promise<In
445
545
  case "gemini":
446
546
  return removeGeminiHooks(path);
447
547
  case "vscode":
448
- return removeVscodeHooks(path);
548
+ return removeVscodeHooks(path, owner);
449
549
  case "kiro":
450
550
  return removeKiroHooks(path);
451
551
  case "windsurf":
@@ -95,7 +95,9 @@ async function applyHooks(agent: AgentTarget, mode: InstallMode): Promise<WriteR
95
95
  }
96
96
 
97
97
  const action =
98
- mode === "install" ? await mergeHooks(format, path) : await removeHooks(format, path);
98
+ mode === "install"
99
+ ? await mergeHooks(format, path, agent.id)
100
+ : await removeHooks(format, path, agent.id);
99
101
 
100
102
  return { path, action };
101
103
  }
@@ -117,11 +117,15 @@ export class IndexCache {
117
117
  ref: string | null | undefined,
118
118
  cacheKey: string,
119
119
  ): Promise<MiruIndex> {
120
+ const entry = this.ensureEntry(cacheKey, source);
120
121
  const task = (async () => {
121
122
  const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
122
123
  if (!isGitUrl(source)) {
123
124
  await index.saveToCache(resolve(source));
124
125
  }
126
+ // Publish before stale reconciliation so flushFileUpdates can use
127
+ // entry.index instead of awaiting this same task (self-deadlock).
128
+ entry.index = index;
125
129
  if (index.loadedFromDisk) {
126
130
  // Await reconciliation here (not fire-and-forget) so callers of `get()`
127
131
  // never observe an index that's stale relative to what's on disk.
@@ -130,7 +134,6 @@ export class IndexCache {
130
134
  return index;
131
135
  })();
132
136
 
133
- const entry = this.ensureEntry(cacheKey, source);
134
137
  entry.task = task;
135
138
  void task
136
139
  .then((index) => {
@@ -141,6 +144,7 @@ export class IndexCache {
141
144
  .catch(() => {
142
145
  if (entry.task === task) {
143
146
  entry.task = null;
147
+ entry.index = null;
144
148
  }
145
149
  });
146
150
 
@@ -225,7 +229,11 @@ export class IndexCache {
225
229
  this.scheduleFlush(cacheKey, source, entry);
226
230
  }
227
231
 
228
- private flushFileUpdates(cacheKey: string, source: string): Promise<void> {
232
+ private flushFileUpdates(
233
+ cacheKey: string,
234
+ source: string,
235
+ knownIndex?: MiruIndex,
236
+ ): Promise<void> {
229
237
  const entry = this.entries.get(cacheKey);
230
238
  if (!entry) {
231
239
  return Promise.resolve();
@@ -239,7 +247,9 @@ export class IndexCache {
239
247
  return;
240
248
  }
241
249
 
242
- let index = entry.index;
250
+ // Prefer an index already in hand (or published on the entry). Never await
251
+ // entry.task while that task is itself waiting on this flush — that deadlocks.
252
+ let index = knownIndex ?? entry.index;
243
253
  if (!index && entry.task) {
244
254
  try {
245
255
  index = await entry.task;
@@ -318,7 +328,7 @@ export class IndexCache {
318
328
  }
319
329
 
320
330
  if (entry.pendingPaths.size > 0) {
321
- await this.flushFileUpdates(cacheKey, source);
331
+ await this.flushFileUpdates(cacheKey, source, index);
322
332
  }
323
333
  } catch {}
324
334
  }