@takara-ai/miru-code 1.9.0 → 1.10.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 (60) hide show
  1. package/README.md +43 -48
  2. package/docs/coverage-badge.json +6 -0
  3. package/grammars/manifest.json +10 -2
  4. package/grammars/tree-sitter-astro.wasm +0 -0
  5. package/grammars/tree-sitter-php.wasm +0 -0
  6. package/grammars/tree-sitter-php_only.wasm +0 -0
  7. package/grammars/tree-sitter-sql.wasm +0 -0
  8. package/grammars/tree-sitter-svelte.wasm +0 -0
  9. package/grammars/tree-sitter-vue.wasm +0 -0
  10. package/package.json +10 -2
  11. package/src/agents.ts +4 -4
  12. package/src/auth/device.ts +26 -21
  13. package/src/auth/providers.ts +26 -11
  14. package/src/benchmark/compare.ts +3 -10
  15. package/src/benchmark/grep.ts +64 -37
  16. package/src/benchmark/locate-compare.ts +8 -1
  17. package/src/benchmark/rg-literal.ts +9 -4
  18. package/src/cache.ts +3 -3
  19. package/src/chunking/ast.ts +21 -3
  20. package/src/chunking/grammars.ts +8 -3
  21. package/src/chunking/structural.ts +13 -24
  22. package/src/cli-mode.ts +18 -0
  23. package/src/cli.ts +10 -45
  24. package/src/credentials.ts +8 -13
  25. package/src/embeddings/openai.ts +6 -3
  26. package/src/embeddings/sagemaker.ts +18 -3
  27. package/src/embeddings/validate.ts +10 -7
  28. package/src/git.ts +6 -2
  29. package/src/help.ts +5 -4
  30. package/src/index/bm25.ts +66 -6
  31. package/src/index/create.ts +5 -9
  32. package/src/index/files.ts +5 -1
  33. package/src/index/incremental.ts +48 -8
  34. package/src/index/sparse.ts +14 -2
  35. package/src/installer/agents.ts +85 -65
  36. package/src/installer/benchmark-mode.ts +40 -11
  37. package/src/installer/config.ts +105 -42
  38. package/src/installer/installer.ts +59 -34
  39. package/src/installer/legacy-hooks.ts +131 -0
  40. package/src/installer/prompt.ts +20 -22
  41. package/src/installer/search-policy.ts +8 -8
  42. package/src/mcp/auth-tool.ts +93 -33
  43. package/src/mcp/index-cache.ts +16 -3
  44. package/src/mcp/server.ts +42 -22
  45. package/src/mcp/stdio.ts +17 -2
  46. package/src/miru-index.ts +54 -18
  47. package/src/prompt.ts +51 -14
  48. package/src/ranking/boosting.ts +5 -6
  49. package/src/ranking/location.ts +6 -10
  50. package/src/ranking/penalties.ts +14 -22
  51. package/src/search.ts +4 -12
  52. package/src/setup.ts +25 -13
  53. package/src/snippet.ts +8 -26
  54. package/src/spinner.ts +26 -16
  55. package/src/tokenizer/bert-wordpiece.ts +0 -6
  56. package/src/utils.ts +0 -4
  57. package/src/version.ts +4 -4
  58. package/src/installer/hooks/install.ts +0 -549
  59. package/src/installer/hooks/opencode-plugin.ts +0 -54
  60. package/src/installer/hooks/search-guard.ts +0 -272
@@ -30,7 +30,7 @@ import {
30
30
  replaceOrAppendMarked,
31
31
  stripJsonComments,
32
32
  } from "./config.ts";
33
- import { mergeHooks, removeHooks } from "./hooks/install.ts";
33
+ import { removeLegacySearchHooks } from "./legacy-hooks.ts";
34
34
  import { promptConfirm, promptMultiSelect, requireInteractiveTerminal } from "./prompt.ts";
35
35
  import { CURSOR_RULES_MDC } from "./search-policy.ts";
36
36
  import {
@@ -56,7 +56,7 @@ export type IntegrationId =
56
56
  | "mcp"
57
57
  | "instructions"
58
58
  | "subagent"
59
- | "hooks"
59
+ | "legacy-hooks"
60
60
  | "rules"
61
61
  | "caveman"
62
62
  | "ste";
@@ -72,6 +72,21 @@ export interface ApplyCtx {
72
72
  isDetected?: (agent: AgentTarget) => Promise<boolean>;
73
73
  }
74
74
 
75
+ type InstallerMultiSelect = <T>(
76
+ title: string,
77
+ items: Array<{ label: string; value: T; checked: boolean }>,
78
+ ) => Promise<T[] | null>;
79
+
80
+ export interface RunInstallerDependencies {
81
+ requireInteractiveTerminal?: (command: string) => void;
82
+ ensureCredentials?: typeof ensureCredentials;
83
+ agents?: AgentTarget[];
84
+ isAgentDetected?: (agent: AgentTarget) => Promise<boolean>;
85
+ promptMultiSelect?: InstallerMultiSelect;
86
+ promptConfirm?: typeof promptConfirm;
87
+ removeUninstallLocalData?: typeof removeUninstallLocalData;
88
+ }
89
+
75
90
  interface Integration {
76
91
  id: IntegrationId;
77
92
  label: string;
@@ -110,10 +125,12 @@ async function readExistingJsonMcpEntry(
110
125
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
111
126
  return null;
112
127
  }
113
- const section = (parsed as Record<string, unknown>)[sectionKey];
114
- if (!section || typeof section !== "object" || Array.isArray(section)) {
115
- return null;
128
+ let section: unknown = parsed;
129
+ for (const key of sectionKey.split(".")) {
130
+ if (!section || typeof section !== "object" || Array.isArray(section)) return null;
131
+ section = (section as Record<string, unknown>)[key];
116
132
  }
133
+ if (!section || typeof section !== "object" || Array.isArray(section)) return null;
117
134
  const entry = (section as Record<string, unknown>)[memberKey];
118
135
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
119
136
  return null;
@@ -161,18 +178,13 @@ async function applyInstructions(
161
178
  return { path, action };
162
179
  }
163
180
 
164
- async function applyHooks(agent: AgentTarget, mode: InstallMode): Promise<WriteResult | null> {
165
- const path = agent.hooksPath;
166
- const format = agent.hooksFormat;
181
+ async function removeLegacyHooks(agent: AgentTarget): Promise<WriteResult | null> {
182
+ const path = agent.legacyHooksPath;
183
+ const format = agent.legacyHooksFormat;
167
184
  if (!path || !format) {
168
185
  return null;
169
186
  }
170
-
171
- const action =
172
- mode === "install"
173
- ? await mergeHooks(format, path, agent.id)
174
- : await removeHooks(format, path, agent.id);
175
-
187
+ const action = await removeLegacySearchHooks(format, path, agent.id);
176
188
  return { path, action };
177
189
  }
178
190
 
@@ -311,9 +323,6 @@ async function applyCaveman(
311
323
 
312
324
  async function stePackMatches(skillDir: string): Promise<boolean> {
313
325
  const skillPath = join(skillDir, "SKILL.md");
314
- if (!(await Bun.file(skillPath).exists())) {
315
- return false;
316
- }
317
326
  if ((await Bun.file(skillPath).text()) !== STE_SKILL_MD) {
318
327
  return false;
319
328
  }
@@ -449,13 +458,12 @@ const INTEGRATIONS: Integration[] = [
449
458
  apply: applyCursorRules,
450
459
  },
451
460
  {
452
- id: "hooks",
453
- label: "Search hooks",
454
- description: "blocks built-in search; routes to Miru MCP",
455
- experimental: true,
456
- defaultChecked: false,
457
- planPath: (agent) => agent.hooksPath,
458
- apply: applyHooks,
461
+ id: "legacy-hooks",
462
+ label: "Remove old Miru hooks",
463
+ description: "clean up stale search hooks from older releases",
464
+ defaultChecked: true,
465
+ planPath: (agent) => agent.legacyHooksPath,
466
+ apply: removeLegacyHooks,
459
467
  },
460
468
  {
461
469
  id: "caveman",
@@ -593,12 +601,25 @@ export async function removeUninstallLocalData(): Promise<{
593
601
  };
594
602
  }
595
603
 
596
- export async function runInstaller(mode: InstallMode): Promise<void> {
604
+ export async function runInstaller(
605
+ mode: InstallMode,
606
+ dependencies: RunInstallerDependencies = {},
607
+ ): Promise<void> {
608
+ const deps = {
609
+ requireInteractiveTerminal,
610
+ ensureCredentials,
611
+ agents: AGENT_TARGETS,
612
+ isAgentDetected,
613
+ promptMultiSelect,
614
+ promptConfirm,
615
+ removeUninstallLocalData,
616
+ ...dependencies,
617
+ };
597
618
  const install = mode === "install";
598
- requireInteractiveTerminal(`miru ${mode}`);
619
+ deps.requireInteractiveTerminal(`miru ${mode}`);
599
620
 
600
621
  if (install) {
601
- await ensureCredentials({ interactive: true });
622
+ await deps.ensureCredentials({ interactive: true });
602
623
  }
603
624
 
604
625
  writeStdout("");
@@ -607,9 +628,9 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
607
628
  hint("↑↓ move space select enter confirm");
608
629
 
609
630
  const detected = await Promise.all(
610
- AGENT_TARGETS.map(async (agent) => ({
631
+ deps.agents.map(async (agent) => ({
611
632
  agent,
612
- detected: await isAgentDetected(agent),
633
+ detected: await deps.isAgentDetected(agent),
613
634
  })),
614
635
  );
615
636
 
@@ -619,7 +640,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
619
640
  checked: isDetected && install,
620
641
  }));
621
642
 
622
- const chosenAgents = await promptMultiSelect(
643
+ const chosenAgents = await deps.promptMultiSelect(
623
644
  `Agents to ${install ? "configure" : "clean up"}`,
624
645
  agentItems,
625
646
  );
@@ -641,7 +662,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
641
662
  checked: install ? (integration.defaultChecked ?? true) : true,
642
663
  }));
643
664
 
644
- const chosenIntegrations = await promptMultiSelect(
665
+ const chosenIntegrations = await deps.promptMultiSelect(
645
666
  `Integrations to ${install ? "enable" : "remove"}`,
646
667
  integrationItems,
647
668
  );
@@ -653,7 +674,10 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
653
674
 
654
675
  await printPlan(mode, chosenAgents, chosenIntegrations);
655
676
 
656
- const proceed = await promptConfirm(install ? "Proceed?" : "Remove miru configuration?", install);
677
+ const proceed = await deps.promptConfirm(
678
+ install ? "Proceed?" : "Remove miru configuration?",
679
+ install,
680
+ );
657
681
  if (!proceed) {
658
682
  hint("Cancelled.");
659
683
  return;
@@ -662,7 +686,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
662
686
  await apply(mode, chosenAgents, chosenIntegrations);
663
687
 
664
688
  if (!install) {
665
- const local = await removeUninstallLocalData();
689
+ const local = await deps.removeUninstallLocalData();
666
690
  if (local.benchmarkHistoryCleared) {
667
691
  info(`Removed benchmark report ${local.benchmarkHistoryPath}`);
668
692
  }
@@ -674,7 +698,6 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
674
698
 
675
699
  export {
676
700
  applyCaveman,
677
- applyHooks,
678
701
  applyInstructions,
679
702
  applyMcp,
680
703
  applySte,
@@ -684,3 +707,5 @@ export {
684
707
  mergeJsonMember as mergeMcpJson,
685
708
  removeJsonMember as removeMcpJson,
686
709
  };
710
+
711
+ export const installerTestUtils = { applyCursorRules, printPlan };
@@ -0,0 +1,131 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import type { InstallAction, LegacyHooksFormat } from "./agents.ts";
3
+ import { stripJsonComments } from "./config.ts";
4
+ import { isCopilotFamilyId } from "./copilot-family.ts";
5
+
6
+ const LEGACY_MARKERS = ["hook-guard", "miru-search"];
7
+ const VSCODE_OWNERS_KEY = "miruOwners";
8
+
9
+ function isLegacyCommand(value: unknown): boolean {
10
+ return typeof value === "string" && LEGACY_MARKERS.some((marker) => value.includes(marker));
11
+ }
12
+
13
+ function parseObject(text: string): Record<string, unknown> | null {
14
+ try {
15
+ const value: unknown = JSON.parse(stripJsonComments(text));
16
+ return value && typeof value === "object" && !Array.isArray(value)
17
+ ? (value as Record<string, unknown>)
18
+ : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function object(value: unknown): Record<string, unknown> | null {
25
+ return value && typeof value === "object" && !Array.isArray(value)
26
+ ? (value as Record<string, unknown>)
27
+ : null;
28
+ }
29
+
30
+ function hasOnlyMiruConfig(data: Record<string, unknown>): boolean {
31
+ const keys = Object.keys(data);
32
+ if (keys.length === 0 || keys.every((key) => key === "version")) return true;
33
+ const hooks = object(data.hooks);
34
+ return (
35
+ !!hooks &&
36
+ Object.keys(data).every((key) => key === "version" || key === "hooks") &&
37
+ Object.keys(hooks).length === 0
38
+ );
39
+ }
40
+
41
+ async function writeResult(path: string, data: Record<string, unknown>): Promise<void> {
42
+ if (hasOnlyMiruConfig(data)) {
43
+ await unlink(path);
44
+ } else {
45
+ await Bun.write(path, `${JSON.stringify(data, null, 2)}\n`);
46
+ }
47
+ }
48
+
49
+ function isMiruEntry(format: LegacyHooksFormat, entry: Record<string, unknown>): boolean {
50
+ if (format === "claude") {
51
+ const nested = Array.isArray(entry.hooks) ? entry.hooks : [];
52
+ return nested.some((hook) => isLegacyCommand(object(hook)?.command));
53
+ }
54
+ return isLegacyCommand(entry.command);
55
+ }
56
+
57
+ /** Remove search hooks installed by older Miru releases; Miru no longer installs hooks. */
58
+ export async function removeLegacySearchHooks(
59
+ format: LegacyHooksFormat,
60
+ path: string,
61
+ owner?: string,
62
+ ): Promise<InstallAction> {
63
+ if (!(await Bun.file(path).exists())) return "not-found";
64
+
65
+ if (format === "opencode") {
66
+ await unlink(path);
67
+ return "removed";
68
+ }
69
+
70
+ const text = await Bun.file(path).text();
71
+ const data = parseObject(text);
72
+ if (!data) return "error";
73
+
74
+ const hooks = object(data.hooks);
75
+
76
+ let ownerChanged = false;
77
+ if (format === "vscode") {
78
+ const owners = Array.isArray(data[VSCODE_OWNERS_KEY])
79
+ ? (data[VSCODE_OWNERS_KEY] as unknown[]).filter(
80
+ (value): value is string => typeof value === "string" && isCopilotFamilyId(value),
81
+ )
82
+ : null;
83
+ if (owners && owner) {
84
+ const remaining = owners.filter((value) => value !== owner);
85
+ if (remaining.length > 0) {
86
+ data[VSCODE_OWNERS_KEY] = remaining;
87
+ await Bun.write(path, `${JSON.stringify(data, null, 2)}\n`);
88
+ return "updated";
89
+ }
90
+ delete data[VSCODE_OWNERS_KEY];
91
+ ownerChanged = true;
92
+ }
93
+ }
94
+
95
+ if (!hooks) {
96
+ if (!ownerChanged) return "not-found";
97
+ await writeResult(path, data);
98
+ return "removed";
99
+ }
100
+
101
+ const eventKey = {
102
+ claude: "PreToolUse",
103
+ cursor: "preToolUse",
104
+ gemini: "BeforeTool",
105
+ vscode: "PreToolUse",
106
+ kiro: "preToolUse",
107
+ windsurf: "pre_run_command",
108
+ opencode: "",
109
+ }[format];
110
+ const entries = Array.isArray(hooks[eventKey]) ? (hooks[eventKey] as unknown[]) : null;
111
+ if (!entries) {
112
+ if (!ownerChanged) return "not-found";
113
+ await writeResult(path, data);
114
+ return "removed";
115
+ }
116
+
117
+ const retained = entries.filter((entry) => {
118
+ const record = object(entry);
119
+ return !record || !isMiruEntry(format, record);
120
+ });
121
+ if (retained.length === entries.length) {
122
+ if (!ownerChanged) return "not-found";
123
+ await writeResult(path, data);
124
+ return "removed";
125
+ }
126
+ if (retained.length === 0) delete hooks[eventKey];
127
+ else hooks[eventKey] = retained;
128
+ if (Object.keys(hooks).length === 0) delete data.hooks;
129
+ await writeResult(path, data);
130
+ return "removed";
131
+ }
@@ -113,18 +113,9 @@ function parseArrowKey(chunk: Buffer, text: string): string | null {
113
113
  const introducer = text.charAt(1);
114
114
  if (introducer === "[" || introducer === "O") {
115
115
  const arrow = text.charAt(text.length - 1);
116
- if (arrow === "A") {
117
- return "up";
118
- }
119
- if (arrow === "B") {
120
- return "down";
121
- }
122
- if (arrow === "C") {
123
- return "right";
124
- }
125
- if (arrow === "D") {
126
- return "left";
127
- }
116
+ return (
117
+ ({ A: "up", B: "down", C: "right", D: "left" } as Record<string, string>)[arrow] ?? null
118
+ );
128
119
  }
129
120
  }
130
121
 
@@ -308,9 +299,13 @@ function updateConfirmPromptLine(question: string, yesSelected: boolean): void {
308
299
  );
309
300
  }
310
301
 
311
- export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
302
+ export async function promptConfirm(
303
+ question: string,
304
+ defaultYes = true,
305
+ io: { input?: typeof input; output?: typeof output } = {},
306
+ ): Promise<boolean> {
312
307
  if (!input.isTTY) {
313
- const rl = readline.createInterface({ input, output });
308
+ const rl = readline.createInterface({ input: io.input ?? input, output: io.output ?? output });
314
309
  try {
315
310
  const hintText = defaultYes ? dim("[Y/n]") : dim("[y/N]");
316
311
  const answer = (await rl.question(`${question} ${hintText} `)).trim().toLowerCase();
@@ -354,10 +349,13 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
354
349
  export async function promptMultiSelectLegacy<T>(
355
350
  title: string,
356
351
  items: Array<{ label: string; value: T; checked: boolean }>,
352
+ io: { input?: typeof input; output?: typeof output } = {},
357
353
  ): Promise<T[] | null> {
358
- output.write("\n");
359
- output.write(`${bold(title)}\n`);
360
- divider("─", 48, output);
354
+ const promptInput = io.input ?? input;
355
+ const promptOutput = io.output ?? output;
356
+ promptOutput.write("\n");
357
+ promptOutput.write(`${bold(title)}\n`);
358
+ divider("─", 48, promptOutput);
361
359
 
362
360
  for (let i = 0; i < items.length; i++) {
363
361
  const item = items[i];
@@ -365,13 +363,13 @@ export async function promptMultiSelectLegacy<T>(
365
363
  continue;
366
364
  }
367
365
  const mark = item.checked ? cyan("[x]") : dim("[ ]");
368
- output.write(` ${dim(String(i + 1).padStart(2))}. ${mark} ${item.label}\n`);
366
+ promptOutput.write(` ${dim(String(i + 1).padStart(2))}. ${mark} ${item.label}\n`);
369
367
  }
370
368
 
371
- output.write("\n");
372
- hint("Enter numbers (1,3), 'all', or press Enter for defaults", output);
369
+ promptOutput.write("\n");
370
+ hint("Enter numbers (1,3), 'all', or press Enter for defaults", promptOutput);
373
371
 
374
- const rl = readline.createInterface({ input, output });
372
+ const rl = readline.createInterface({ input: promptInput, output: promptOutput });
375
373
  try {
376
374
  const answer = (await rl.question(`${dim("> ")}`)).toLowerCase();
377
375
  if (!answer) {
@@ -390,7 +388,7 @@ export async function promptMultiSelectLegacy<T>(
390
388
  }
391
389
 
392
390
  if (indices.size === 0) {
393
- warn("No valid selection.", output);
391
+ warn("No valid selection.", promptOutput);
394
392
  return null;
395
393
  }
396
394
 
@@ -28,15 +28,17 @@ export function buildSearchPolicyBody(native: NativeToolNames): string {
28
28
 
29
29
  ${SNIPPET_GUIDANCE}
30
30
 
31
+ \`repo\` is optional, set it for another repo.
32
+
31
33
  Use Miru MCP tools:
32
34
  - \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
33
- - \`search\` — one call per conceptual question; pass project root as \`repo\`; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
35
+ - \`search\` — one call per conceptual question; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
34
36
  - \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
35
37
  - \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
36
38
 
37
39
  Call shapes:
38
- - \`locate(literal="<exact substring>", repo="<project root>")\` — prefer \`mode="locations"\`/\`"count"\`
39
- - \`search(query="<natural language>", repo="<project root>", include=["src/auth/**"])\` — use the same \`include\` / \`exclude\` globs on \`locate\` when scoping an exact-string search
40
+ - \`locate(literal="<exact substring>")\` — prefer \`mode="locations"\`/\`"count"\`
41
+ - \`search(query="<natural language>", include=["src/auth/**"])\` — use the same \`include\` / \`exclude\` globs on \`locate\` when scoping an exact-string search
40
42
 
41
43
  Stop rules:
42
44
  - Literal in the request → \`locate\`, not \`search\`
@@ -54,7 +56,7 @@ ${buildSearchPolicyTable(native)}`;
54
56
  export function buildMcpWorkflow(native: NativeToolNames): string {
55
57
  return `### MCP workflow
56
58
 
57
- 1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
59
+ 1. Call \`search\`; add \`repo\` only for another repo.
58
60
  2. For exact literals, call \`locate\` (prefer \`mode=count\` or \`locations\`).
59
61
  3. On \`truncated: true\`, call \`expand\` — only if the snippet doesn't already answer.
60
62
  4. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
@@ -71,7 +73,7 @@ miru find-related src/auth.ts 42 .
71
73
  \`\`\`
72
74
 
73
75
  After large refactors or CLI-only use, run \`miru clear <path>\` to rebuild the disk cache.
74
- If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
76
+ If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code@latest\`.`;
75
77
 
76
78
  export function buildSubagentBody(native: NativeToolNames): string {
77
79
  return `When Miru MCP is available, use MCP \`search\`, \`locate\`, \`expand\`, and \`find_related\` — not ${native.explorationDenied} for exploration.
@@ -118,7 +120,7 @@ export const MCP_SERVER_INSTRUCTIONS =
118
120
  "Exact token (identifier, quoted string, env var, error code) → `locate`; everything else → `search` once, no paraphrase retries. " +
119
121
  `${SNIPPET_GUIDANCE} ` +
120
122
  "`find_related` traces similar code elsewhere, not more context in the same file. " +
121
- "Always pass the project root as `repo`; local repos return `absolute_path` — use Read only to edit. " +
123
+ "`repo` is optional, good for multi repo workflows. " +
122
124
  "Native Grep/Glob only outside the indexed repo or for non-code tasks. " +
123
125
  "Default indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
124
126
  "On credential errors, call this server's `auth` tool — not the host `mcp_auth` tool.";
@@ -161,5 +163,3 @@ export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
161
163
  export const MCP_READ_BENCHMARK_TOOL_DESCRIPTION =
162
164
  "Cumulative Miru vs Grep token savings from saved `search` and `locate` calls. Returns compact totals {n,saved,save_pct,miru,grep}. " +
163
165
  "Do not call unless the user asks about savings.";
164
-
165
- export const SEARCH_GUARD_EXPAND_HINT = SNIPPET_GUIDANCE;
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
  import {
3
3
  checkDeviceAuthorizationOnce,
4
4
  type DeviceAuthConfig,
5
+ type DeviceAuthorizationCheck,
5
6
  type DeviceAuthorizationStart,
6
7
  openBrowserForDeviceLogin,
7
8
  resolveDeviceAuthConfig,
@@ -15,24 +16,37 @@ import type { MiruMcpServer } from "./runtime.ts";
15
16
  type ToolResult = ReturnType<typeof toolText>;
16
17
 
17
18
  const CHECK_HINT =
18
- 'Once the user approves, call `auth` again with action "check" to finish signing in.';
19
+ "If the user has not approved yet, call `auth` again to keep waiting; it will not open a second tab.";
20
+
21
+ /**
22
+ * How long one `auth` call waits for the user to approve in the browser. Kept well under
23
+ * typical MCP client tool timeouts; a slower login just takes another call.
24
+ */
25
+ const DEFAULT_WAIT_MS = 45_000;
26
+ const SLOW_DOWN_STEP_MS = 5_000;
27
+ /** The server may advertise `interval: 0`; never poll the token endpoint faster than this. */
28
+ const MIN_POLL_INTERVAL_MS = 1_000;
19
29
 
20
30
  /** Appended to credentials failures so an agent knows Miru's own recovery step. */
21
31
  const RECOVERY_HINT =
22
- 'Miru could not authorize its current credentials. Call the `auth` tool with action "start" ' +
23
- 'to open the Takara device-login page, then action "check" once the user approves. If access ' +
24
- "is still denied after signing in, check the account token balance.";
32
+ "Miru could not authorize its current credentials. Call the `auth` tool to open the Takara " +
33
+ "device-login page; it waits for the user to approve. If access is still denied after " +
34
+ "signing in, check the account token balance.";
25
35
 
26
36
  const AUTH_TOOL_DESCRIPTION =
27
37
  "Sign in with Takara credentials via device-code login — no terminal required. " +
28
38
  "Only call this in direct response to a tool error mentioning missing, expired, rejected, or " +
29
39
  "invalid credentials — never speculatively, since it starts a real sign-in prompt for the " +
30
- 'user. Call with no arguments (or action "start") to begin: it opens the device-login ' +
31
- `page in the user's browser and returns that URL plus a short code. ${CHECK_HINT}`;
40
+ "user. Call with no arguments: it opens the device-login page in the user's browser, then " +
41
+ "waits (up to about 45 seconds) for them to approve and returns once they have. If it " +
42
+ 'returns "still waiting", tell the user the URL and code and call `auth` again.';
32
43
 
33
44
  /** Browser opener seam; the real one spawns a detached `open`/`xdg-open`/`start`. */
34
45
  export type BrowserOpener = (url: string) => boolean;
35
46
 
47
+ /** Sleep seam so tests can drive the wait loop without real delays. */
48
+ export type Sleeper = (ms: number) => Promise<void>;
49
+
36
50
  /** Opening is on unless MIRU_OPEN_BROWSER is set to something other than "1" (matches `miru setup`). */
37
51
  function openIfAllowed(open: BrowserOpener, url: string): boolean {
38
52
  const raw = process.env.MIRU_OPEN_BROWSER;
@@ -42,8 +56,14 @@ function openIfAllowed(open: BrowserOpener, url: string): boolean {
42
56
  return open(url);
43
57
  }
44
58
 
45
- function approvalText(link: string, userCode: string, opened: boolean): string {
46
- const lead = opened ? `A browser tab is open at ${link}` : `Ask the user to open ${link}`;
59
+ /** `opened` is null when resuming an existing login (no new tab was opened). */
60
+ function approvalText(link: string, userCode: string, opened: boolean | null): string {
61
+ const lead =
62
+ opened === null
63
+ ? `Still waiting for approval at ${link}`
64
+ : opened
65
+ ? `A browser tab is open at ${link}`
66
+ : `Ask the user to open ${link}`;
47
67
  return `${lead}. Code: ${userCode}. ${CHECK_HINT}`;
48
68
  }
49
69
 
@@ -71,11 +91,17 @@ function isExpired(entry: PendingDeviceAuth): boolean {
71
91
  class AuthToolState {
72
92
  private pending: PendingDeviceAuth | null = null;
73
93
 
74
- constructor(private readonly openBrowser: BrowserOpener) {}
94
+ constructor(
95
+ private readonly openBrowser: BrowserOpener,
96
+ private readonly waitMs: number,
97
+ private readonly sleep: Sleeper,
98
+ ) {}
75
99
 
76
100
  async start(): Promise<ToolResult> {
77
101
  let pending = this.pending;
102
+ let fresh = false;
78
103
  if (!pending || isExpired(pending)) {
104
+ fresh = true;
79
105
  const config = resolveDeviceAuthConfig();
80
106
  const start = await startDeviceAuthorization({ config });
81
107
  pending = { start, config, startedAtMs: Date.now() };
@@ -84,21 +110,33 @@ class AuthToolState {
84
110
 
85
111
  const { verificationUriComplete, verificationUri, userCode } = pending.start;
86
112
  const link = verificationUriComplete ?? verificationUri;
87
- // Reopening on a repeat call is deliberate: the user may have closed the first tab.
88
- return toolText(approvalText(link, userCode, openIfAllowed(this.openBrowser, link)));
113
+ // Only a new login opens a tab; a repeat call just resumes the wait.
114
+ const opened = fresh ? openIfAllowed(this.openBrowser, link) : null;
115
+
116
+ const settled = await this.waitForApproval(pending);
117
+ return settled ?? toolText(approvalText(link, userCode, opened));
89
118
  }
90
119
 
91
- async check(): Promise<ToolResult> {
92
- if (!this.pending) {
93
- return toolText('No device login is pending. Call `auth` with action "start" first.');
120
+ /** Poll until approved, denied or expired, or the wait budget runs out (then null). */
121
+ private async waitForApproval(entry: PendingDeviceAuth): Promise<ToolResult | null> {
122
+ const deadline = Date.now() + this.waitMs;
123
+ let intervalMs = Math.max(entry.start.interval * 1000, MIN_POLL_INTERVAL_MS);
124
+ while (Date.now() + intervalMs <= deadline) {
125
+ await this.sleep(intervalMs);
126
+ const result = await checkDeviceAuthorizationOnce(entry.start, { config: entry.config });
127
+ const settled = await this.settle(result);
128
+ if (settled) {
129
+ return settled;
130
+ }
131
+ if (result.status === "slow_down") {
132
+ intervalMs += SLOW_DOWN_STEP_MS;
133
+ }
94
134
  }
135
+ return null;
136
+ }
95
137
 
96
- const { start, config } = this.pending;
97
- // Errors (network failure, unrecognized OAuth error code) intentionally leave
98
- // `pending` intact — a transient failure shouldn't force the user to restart
99
- // the whole login, just retry the check.
100
- const result = await checkDeviceAuthorizationOnce(start, { config });
101
-
138
+ /** Handle a final poll result; null means the login is still pending. */
139
+ private async settle(result: DeviceAuthorizationCheck): Promise<ToolResult | null> {
102
140
  switch (result.status) {
103
141
  case "success": {
104
142
  this.pending = null;
@@ -114,31 +152,51 @@ class AuthToolState {
114
152
  setStoredCredentialsEnvToken(tokens.accessToken);
115
153
  return toolText("Signed in successfully. Miru tools are now ready to use.");
116
154
  }
117
- case "pending":
118
- return toolText(
119
- 'Still waiting for approval. Ask the user to confirm they clicked and approved, then call `auth` again with action "check".',
120
- );
121
- case "slow_down":
122
- return toolText(
123
- 'Checking too soon — wait a bit before calling `auth` again with action "check".',
124
- );
125
155
  case "denied":
126
156
  this.pending = null;
127
- return toolText('Sign-in was denied. Call `auth` with action "start" to try again.');
157
+ return toolText("Sign-in was denied. Call `auth` again to try again.");
128
158
  case "expired":
129
159
  this.pending = null;
130
160
  return toolText(
131
- 'The device code expired before it was approved. Call `auth` with action "start" to try again.',
161
+ "The device code expired before it was approved. Call `auth` again to get a new one.",
132
162
  );
163
+ default:
164
+ return null;
165
+ }
166
+ }
167
+
168
+ async check(): Promise<ToolResult> {
169
+ if (!this.pending) {
170
+ return toolText('No device login is pending. Call `auth` with action "start" first.');
133
171
  }
172
+
173
+ const { start, config } = this.pending;
174
+ // Errors (network failure, unrecognized OAuth error code) intentionally leave
175
+ // `pending` intact — a transient failure shouldn't force the user to restart
176
+ // the whole login, just retry the check.
177
+ const result = await checkDeviceAuthorizationOnce(start, { config });
178
+
179
+ const settled = await this.settle(result);
180
+ if (settled) {
181
+ return settled;
182
+ }
183
+ return toolText(
184
+ result.status === "slow_down"
185
+ ? "Checking too soon — wait a bit before calling `auth` again."
186
+ : "Still waiting for approval. Ask the user to confirm they clicked and approved, then call `auth` again.",
187
+ );
134
188
  }
135
189
  }
136
190
 
137
191
  export function registerAuthTool(
138
192
  server: MiruMcpServer,
139
- options?: { openBrowser?: BrowserOpener },
193
+ options?: { openBrowser?: BrowserOpener; waitMs?: number; sleep?: Sleeper },
140
194
  ): void {
141
- const state = new AuthToolState(options?.openBrowser ?? openBrowserForDeviceLogin);
195
+ const state = new AuthToolState(
196
+ options?.openBrowser ?? openBrowserForDeviceLogin,
197
+ options?.waitMs ?? DEFAULT_WAIT_MS,
198
+ options?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
199
+ );
142
200
 
143
201
  server.registerTool(
144
202
  "auth",
@@ -148,7 +206,9 @@ export function registerAuthTool(
148
206
  action: z
149
207
  .enum(["start", "check"])
150
208
  .optional()
151
- .describe('"start" begins a device-code login (default); "check" completes it.'),
209
+ .describe(
210
+ '"start" begins or resumes a device-code login and waits for approval (default); "check" is a single non-blocking poll.',
211
+ ),
152
212
  },
153
213
  },
154
214
  async ({ action }) => {