@oh-my-pi/pi-coding-agent 15.7.5 → 15.8.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 (146) hide show
  1. package/CHANGELOG.md +159 -182
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +11 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  12. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  13. package/dist/types/eval/heartbeat.d.ts +45 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  16. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  18. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  19. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  20. package/dist/types/lsp/index.d.ts +2 -0
  21. package/dist/types/lsp/utils.d.ts +4 -0
  22. package/dist/types/main.d.ts +5 -0
  23. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  24. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  25. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  26. package/dist/types/modes/components/hook-selector.d.ts +10 -1
  27. package/dist/types/modes/components/session-selector.d.ts +32 -5
  28. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  29. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -3
  30. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +10 -3
  32. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +1 -1
  34. package/dist/types/modes/types.d.ts +5 -3
  35. package/dist/types/registry/agent-registry.d.ts +1 -1
  36. package/dist/types/sdk.d.ts +2 -2
  37. package/dist/types/session/agent-session.d.ts +12 -3
  38. package/dist/types/session/history-storage.d.ts +16 -1
  39. package/dist/types/session/session-manager.d.ts +4 -0
  40. package/dist/types/task/output-manager.d.ts +6 -15
  41. package/dist/types/tools/ask.d.ts +8 -6
  42. package/dist/types/tools/eval-backends.d.ts +12 -0
  43. package/dist/types/tools/eval-render.d.ts +52 -0
  44. package/dist/types/tools/eval.d.ts +2 -35
  45. package/dist/types/tools/find.d.ts +0 -9
  46. package/dist/types/tools/index.d.ts +5 -12
  47. package/dist/types/tools/path-utils.d.ts +16 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  49. package/dist/types/tui/output-block.d.ts +4 -3
  50. package/dist/types/utils/clipboard.d.ts +4 -0
  51. package/dist/types/web/kagi.d.ts +76 -0
  52. package/dist/types/web/search/providers/exa.d.ts +7 -1
  53. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  54. package/examples/extensions/README.md +1 -0
  55. package/examples/extensions/thinking-note.ts +13 -0
  56. package/package.json +9 -9
  57. package/src/async/job-manager.ts +3 -3
  58. package/src/cli/args.ts +6 -2
  59. package/src/cli/claude-trace-cli.ts +783 -0
  60. package/src/cli/session-picker.ts +36 -10
  61. package/src/cli/update-cli.ts +35 -2
  62. package/src/commands/launch.ts +3 -0
  63. package/src/config/keybindings.ts +6 -0
  64. package/src/config/model-registry.ts +33 -4
  65. package/src/config/settings-schema.ts +12 -2
  66. package/src/config/settings.ts +23 -0
  67. package/src/discovery/claude-plugins.ts +7 -9
  68. package/src/discovery/claude.ts +41 -22
  69. package/src/edit/index.ts +23 -3
  70. package/src/eval/__tests__/agent-bridge.test.ts +148 -4
  71. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  72. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  73. package/src/eval/agent-bridge.ts +44 -38
  74. package/src/eval/concurrency-bridge.ts +34 -0
  75. package/src/eval/heartbeat.ts +74 -0
  76. package/src/eval/js/executor.ts +13 -9
  77. package/src/eval/js/shared/prelude.txt +20 -17
  78. package/src/eval/js/tool-bridge.ts +5 -0
  79. package/src/eval/llm-bridge.ts +20 -14
  80. package/src/eval/py/executor.ts +14 -18
  81. package/src/eval/py/prelude.py +23 -15
  82. package/src/exec/bash-executor.ts +31 -5
  83. package/src/extensibility/extensions/loader.ts +16 -18
  84. package/src/extensibility/extensions/runner.ts +22 -17
  85. package/src/extensibility/extensions/types.ts +39 -5
  86. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  87. package/src/extensibility/skills.ts +0 -1
  88. package/src/internal-urls/docs-index.generated.ts +14 -13
  89. package/src/lsp/diagnostics-ledger.ts +51 -0
  90. package/src/lsp/index.ts +9 -22
  91. package/src/lsp/utils.ts +21 -0
  92. package/src/main.ts +92 -24
  93. package/src/modes/acp/acp-agent.ts +8 -4
  94. package/src/modes/acp/acp-event-mapper.ts +54 -4
  95. package/src/modes/components/assistant-message.ts +28 -1
  96. package/src/modes/components/custom-editor.ts +19 -7
  97. package/src/modes/components/hook-selector.ts +229 -44
  98. package/src/modes/components/oauth-selector.ts +12 -6
  99. package/src/modes/components/session-selector.ts +179 -24
  100. package/src/modes/components/tool-execution.ts +36 -7
  101. package/src/modes/controllers/command-controller.ts +2 -11
  102. package/src/modes/controllers/event-controller.ts +5 -2
  103. package/src/modes/controllers/extension-ui-controller.ts +6 -4
  104. package/src/modes/controllers/input-controller.ts +19 -16
  105. package/src/modes/controllers/selector-controller.ts +61 -21
  106. package/src/modes/interactive-mode.ts +127 -16
  107. package/src/modes/rpc/rpc-mode.ts +17 -6
  108. package/src/modes/theme/theme-schema.json +30 -0
  109. package/src/modes/theme/theme.ts +39 -2
  110. package/src/modes/types.ts +7 -3
  111. package/src/modes/utils/ui-helpers.ts +5 -2
  112. package/src/prompts/system/orchestrate-notice.md +5 -3
  113. package/src/prompts/system/workflow-notice.md +2 -2
  114. package/src/prompts/tools/ask.md +2 -1
  115. package/src/prompts/tools/eval.md +6 -6
  116. package/src/prompts/tools/find.md +1 -1
  117. package/src/prompts/tools/irc.md +6 -6
  118. package/src/prompts/tools/search.md +1 -1
  119. package/src/prompts/tools/task.md +1 -1
  120. package/src/registry/agent-registry.ts +1 -1
  121. package/src/sdk.ts +85 -31
  122. package/src/session/agent-session.ts +127 -57
  123. package/src/session/history-storage.ts +56 -12
  124. package/src/session/session-manager.ts +34 -0
  125. package/src/task/output-manager.ts +40 -48
  126. package/src/task/render.ts +3 -8
  127. package/src/tools/ask.ts +74 -32
  128. package/src/tools/browser/tab-worker.ts +8 -5
  129. package/src/tools/eval-backends.ts +38 -0
  130. package/src/tools/eval-render.ts +750 -0
  131. package/src/tools/eval.ts +27 -754
  132. package/src/tools/find.ts +5 -29
  133. package/src/tools/index.ts +8 -38
  134. package/src/tools/path-utils.ts +144 -1
  135. package/src/tools/read.ts +47 -0
  136. package/src/tools/renderers.ts +1 -1
  137. package/src/tools/search.ts +2 -27
  138. package/src/tools/sqlite-reader.ts +92 -9
  139. package/src/tools/write.ts +9 -1
  140. package/src/tui/output-block.ts +5 -4
  141. package/src/utils/clipboard.ts +38 -1
  142. package/src/utils/open.ts +37 -2
  143. package/src/web/kagi.ts +168 -49
  144. package/src/web/search/providers/anthropic.ts +1 -1
  145. package/src/web/search/providers/exa.ts +20 -86
  146. package/src/web/search/providers/kagi.ts +4 -0
@@ -1,23 +1,43 @@
1
1
  import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
2
+ import { logger } from "@oh-my-pi/pi-utils";
2
3
  import { SessionSelectorComponent } from "../modes/components/session-selector";
3
- import type { SessionInfo } from "../session/session-manager";
4
+ import { HistoryStorage } from "../session/history-storage";
5
+ import { type SessionInfo, SessionManager } from "../session/session-manager";
4
6
  import { FileSessionStorage } from "../session/session-storage";
5
7
 
6
- /** Show TUI session selector and return selected session path or null if cancelled */
7
- export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
8
- const { promise, resolve } = Promise.withResolvers<string | null>();
8
+ /**
9
+ * Show the TUI session selector and return the selected session, or null if
10
+ * cancelled. Tab toggles between current-folder and all-projects scope; the
11
+ * all-projects list is loaded lazily via `SessionManager.listAll`.
12
+ */
13
+ export async function selectSession(
14
+ sessions: SessionInfo[],
15
+ options?: { allSessions?: SessionInfo[]; startInAllScope?: boolean },
16
+ ): Promise<SessionInfo | null> {
17
+ const { promise, resolve } = Promise.withResolvers<SessionInfo | null>();
9
18
  const ui = new TUI(new ProcessTerminal());
10
19
  let resolved = false;
11
20
  const storage = new FileSessionStorage();
12
21
 
22
+ // Rank sessions with prompt-history matches too, recovering prompts the 4KB
23
+ // session-list prefix never sees. Best-effort: a missing/locked history.db
24
+ // must not break the picker.
25
+ let historyMatcher: ((query: string) => string[]) | undefined;
26
+ try {
27
+ const history = HistoryStorage.open();
28
+ historyMatcher = (query: string) => history.matchingSessionIds(query);
29
+ } catch (error) {
30
+ logger.warn("History storage unavailable for session ranking", { error: String(error) });
31
+ }
32
+
13
33
  const showSelector = () => {
14
34
  const selector = new SessionSelectorComponent(
15
35
  sessions,
16
- (path: string) => {
36
+ (session: SessionInfo) => {
17
37
  if (!resolved) {
18
38
  resolved = true;
19
39
  ui.stop();
20
- resolve(path);
40
+ resolve(session);
21
41
  }
22
42
  },
23
43
  () => {
@@ -34,10 +54,16 @@ export async function selectSession(sessions: SessionInfo[]): Promise<string | n
34
54
  process.exit(0);
35
55
  }
36
56
  },
37
- async (session: SessionInfo) => {
38
- // Delete handler - SessionList will show confirmation internally
39
- await storage.deleteSessionWithArtifacts(session.path);
40
- return true;
57
+ {
58
+ onDelete: async (session: SessionInfo) => {
59
+ // Delete handler - SessionList will show confirmation internally
60
+ await storage.deleteSessionWithArtifacts(session.path);
61
+ return true;
62
+ },
63
+ historyMatcher,
64
+ loadAllSessions: () => SessionManager.listAll(storage),
65
+ allSessions: options?.allSessions,
66
+ startInAllScope: options?.startInAllScope,
41
67
  },
42
68
  );
43
69
  return selector;
@@ -14,6 +14,18 @@ import { theme } from "../modes/theme/theme";
14
14
 
15
15
  const REPO = "can1357/oh-my-pi";
16
16
  const PACKAGE = "@oh-my-pi/pi-coding-agent";
17
+ /**
18
+ * Official npm registry origin.
19
+ *
20
+ * Pinned across both the version check and the bun install step so the two
21
+ * agree on which catalog they are talking to. A user's bun may be pointed at
22
+ * an unofficial mirror (corporate proxy, Taobao, etc.) that lags the upstream
23
+ * registry by minutes-to-hours, in which case `getLatestRelease` would resolve
24
+ * a version the mirror has not yet replicated and the install would fail with
25
+ * `No version matching "X" found for specifier "<pkg>" (but package exists)`.
26
+ * See #1686.
27
+ */
28
+ const NPM_REGISTRY = "https://registry.npmjs.org/";
17
29
 
18
30
  interface ReleaseInfo {
19
31
  tag: string;
@@ -130,7 +142,7 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
130
142
  * Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
131
143
  */
132
144
  async function getLatestRelease(): Promise<ReleaseInfo> {
133
- const response = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`);
145
+ const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`);
134
146
  if (!response.ok) {
135
147
  throw new Error(`Failed to fetch release info: ${response.statusText}`);
136
148
  }
@@ -292,12 +304,33 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
292
304
  }
293
305
  }
294
306
 
307
+ /**
308
+ * Build the bun argv used to globally install a specific omp version.
309
+ *
310
+ * The version is selected by hitting {@link NPM_REGISTRY} directly in
311
+ * {@link getLatestRelease}, so the install MUST observe the same catalog:
312
+ *
313
+ * - `--registry=${NPM_REGISTRY}` pins the install to the official registry
314
+ * regardless of the user's bunfig/`.npmrc`. A mirror (corporate proxy,
315
+ * Taobao, …) that hasn't yet replicated the release would otherwise reject
316
+ * a version the upstream registry already advertises.
317
+ * - `--no-cache` tells bun to ignore its on-disk manifest snapshot so it
318
+ * re-fetches metadata from that registry on every invocation.
319
+ *
320
+ * Together these two flags make `omp update` produce exactly the registry
321
+ * lookup the version check just performed. See #1686.
322
+ */
323
+ export function buildBunInstallArgs(expectedVersion: string): string[] {
324
+ return ["install", "-g", "--no-cache", `--registry=${NPM_REGISTRY}`, `${PACKAGE}@${expectedVersion}`];
325
+ }
326
+
295
327
  /**
296
328
  * Update via bun package manager.
297
329
  */
298
330
  async function updateViaBun(expectedVersion: string): Promise<void> {
299
331
  console.log(chalk.dim("Updating via bun..."));
300
- const result = await $`bun install -g ${PACKAGE}@${expectedVersion}`.nothrow();
332
+ const args = buildBunInstallArgs(expectedVersion);
333
+ const result = await $`bun ${args}`.nothrow();
301
334
  if (result.exitCode !== 0) {
302
335
  throw new Error(`bun install failed with exit code ${result.exitCode}`);
303
336
  }
@@ -90,6 +90,9 @@ export default class Index extends Command {
90
90
  description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
91
91
  options: [...THINKING_EFFORTS],
92
92
  }),
93
+ "hide-thinking": Flags.boolean({
94
+ description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
95
+ }),
93
96
  hook: Flags.string({
94
97
  description: "Load a hook/extension file (can be used multiple times)",
95
98
  multiple: true,
@@ -32,6 +32,7 @@ interface AppKeybindings {
32
32
  "app.message.followUp": true;
33
33
  "app.message.dequeue": true;
34
34
  "app.clipboard.pasteImage": true;
35
+ "app.clipboard.pasteTextRaw": true;
35
36
  "app.clipboard.copyLine": true;
36
37
  "app.clipboard.copyPrompt": true;
37
38
  "app.session.new": true;
@@ -122,6 +123,10 @@ export const KEYBINDINGS = {
122
123
  defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
123
124
  description: "Paste image from clipboard",
124
125
  },
126
+ "app.clipboard.pasteTextRaw": {
127
+ defaultKeys: ["ctrl+shift+v", "alt+shift+v"],
128
+ description: "Paste text from clipboard as raw text (no collapse)",
129
+ },
125
130
  "app.clipboard.copyLine": {
126
131
  defaultKeys: "alt+shift+l",
127
132
  description: "Copy current line",
@@ -214,6 +219,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
214
219
  followUp: "app.message.followUp",
215
220
  dequeue: "app.message.dequeue",
216
221
  pasteImage: "app.clipboard.pasteImage",
222
+ pasteTextRaw: "app.clipboard.pasteTextRaw",
217
223
  copyLine: "app.clipboard.copyLine",
218
224
  copyPrompt: "app.clipboard.copyPrompt",
219
225
  newSession: "app.session.new",
@@ -27,6 +27,32 @@ import {
27
27
  // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
28
28
  const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
29
29
 
30
+ // Default cap on `max_tokens` for auto-discovered models that do not advertise
31
+ // their own output limit (OpenAI-models-list, Ollama, llama.cpp, new-api/
32
+ // one-api proxies). 32K matches the upper end of what mainstream
33
+ // OpenAI-compatible providers (DeepSeek, MiMo, OpenRouter, etc.) actually
34
+ // accept and keeps `min(contextWindow, …)` honoring smaller local windows.
35
+ // Conservative caps below this caused providers to drop the connection
36
+ // mid-stream when models hit the cap on legitimate large tool calls (see
37
+ // issue #1528: `write` payloads >~5KB on deepseek-v4-pro surfaced as
38
+ // "socket connection was closed unexpectedly").
39
+ const DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
40
+
41
+ // Anthropic-safe variant of the discovery cap. The Anthropic stream converter
42
+ // in `packages/ai/src/providers/anthropic.ts` derives the request limit as
43
+ // `(model.maxTokens / 3) | 0`, so the 32K default would surface as 10,922
44
+ // requested output tokens — above the 8,192 hard cap on classic Claude 3.x
45
+ // Sonnet/Haiku/Opus endpoints. Discovered models routed through
46
+ // `anthropic-messages` (proxy `supported_endpoint_types: ["anthropic"]` or a
47
+ // custom provider with `api: anthropic-messages` + openai-models-list
48
+ // discovery) fall back to this conservative value.
49
+ const DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC = 8_192;
50
+
51
+ /** Routes discovered-model `maxTokens` defaults around Anthropic's 3× output divisor. */
52
+ function discoveryDefaultMaxTokens(api: Api | undefined): number {
53
+ return api === "anthropic-messages" ? DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC : DISCOVERY_DEFAULT_MAX_TOKENS;
54
+ }
55
+
30
56
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
31
57
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
32
58
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
@@ -1690,7 +1716,7 @@ export class ModelRegistry {
1690
1716
  input: metadata?.input ?? ["text"],
1691
1717
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1692
1718
  contextWindow: metadata?.contextWindow ?? 128000,
1693
- maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, 8192),
1719
+ maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, DISCOVERY_DEFAULT_MAX_TOKENS),
1694
1720
  headers: providerConfig.headers,
1695
1721
  });
1696
1722
  });
@@ -1760,7 +1786,10 @@ export class ModelRegistry {
1760
1786
  input: serverMetadata?.input ?? ["text"],
1761
1787
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1762
1788
  contextWindow: serverMetadata?.contextWindow ?? 128000,
1763
- maxTokens: Math.min(serverMetadata?.contextWindow ?? Number.POSITIVE_INFINITY, 8192),
1789
+ maxTokens: Math.min(
1790
+ serverMetadata?.contextWindow ?? Number.POSITIVE_INFINITY,
1791
+ DISCOVERY_DEFAULT_MAX_TOKENS,
1792
+ ),
1764
1793
  headers,
1765
1794
  compat: {
1766
1795
  supportsStore: false,
@@ -1807,7 +1836,7 @@ export class ModelRegistry {
1807
1836
  input: ["text"],
1808
1837
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1809
1838
  contextWindow: 128000,
1810
- maxTokens: 8192,
1839
+ maxTokens: discoveryDefaultMaxTokens(providerConfig.api),
1811
1840
  headers,
1812
1841
  compat: {
1813
1842
  supportsStore: false,
@@ -1890,7 +1919,7 @@ export class ModelRegistry {
1890
1919
  // we successfully recover the upstream model identity.
1891
1920
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1892
1921
  contextWindow: reference?.contextWindow ?? 128000,
1893
- maxTokens: reference?.maxTokens ?? 8192,
1922
+ maxTokens: reference?.maxTokens ?? discoveryDefaultMaxTokens(api),
1894
1923
  headers,
1895
1924
  // OpenAI-compat fields are no-ops on anthropic models; the
1896
1925
  // Anthropic SDK ignores them. Provider-level disableStrictTools
@@ -1966,6 +1966,16 @@ export const SETTINGS_SCHEMA = {
1966
1966
  },
1967
1967
  },
1968
1968
 
1969
+ "lsp.diagnosticsDeduplicate": {
1970
+ type: "boolean",
1971
+ default: true,
1972
+ ui: {
1973
+ tab: "editing",
1974
+ label: "Deduplicate Diagnostics",
1975
+ description: "Suppress post-edit LSP diagnostics already shown for a file; only surface new or changed ones",
1976
+ },
1977
+ },
1978
+
1969
1979
  // Bash interceptor
1970
1980
  "bashInterceptor.enabled": {
1971
1981
  type: "boolean",
@@ -2884,7 +2894,7 @@ export const SETTINGS_SCHEMA = {
2884
2894
  label: "Auto",
2885
2895
  description: "Preferred web-search provider",
2886
2896
  },
2887
- { value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" },
2897
+ { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
2888
2898
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
2889
2899
  { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
2890
2900
  { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
@@ -2910,7 +2920,7 @@ export const SETTINGS_SCHEMA = {
2910
2920
  },
2911
2921
  { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
2912
2922
  { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" },
2913
- { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
2923
+ { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY (Kagi V1 Search API)" },
2914
2924
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
2915
2925
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
2916
2926
  { value: "searxng", label: "SearXNG", description: "Requires SEARXNG_ENDPOINT or searxng.endpoint" },
@@ -365,6 +365,29 @@ export class Settings {
365
365
  return cloned;
366
366
  }
367
367
 
368
+ /**
369
+ * Re-scope this instance to a new working directory *in place*: reload the
370
+ * project layer (`.claude/settings.yml` etc.) from `cwd`, re-resolve
371
+ * path-scoped settings against it, and re-fire side-effect hooks (theme,
372
+ * symbols, tab width, …). Global settings and runtime overrides are preserved.
373
+ *
374
+ * Unlike {@link cloneForCwd}, this mutates the live instance, so every holder
375
+ * (the `settings` proxy, the active session, controllers) observes the new
376
+ * project scope without swapping references — used when the process changes
377
+ * directory mid-run (`/move`, cross-project resume). No-op when `cwd` is
378
+ * already the current scope.
379
+ */
380
+ async reloadForCwd(cwd: string): Promise<void> {
381
+ const normalized = path.normalize(cwd);
382
+ if (normalized === this.#cwd) return;
383
+ this.#cwd = normalized;
384
+ if (this.#persist) {
385
+ this.#project = await this.#loadProjectSettings();
386
+ }
387
+ this.#rebuildMerged();
388
+ this.#fireAllHooks();
389
+ }
390
+
368
391
  // ─────────────────────────────────────────────────────────────────────────
369
392
  // Accessors
370
393
  // ─────────────────────────────────────────────────────────────────────────
@@ -99,10 +99,8 @@ async function resolvePluginDir(
99
99
  async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
100
100
  const items: Skill[] = [];
101
101
  const warnings: string[] = [];
102
-
103
102
  const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
104
103
  warnings.push(...rootWarnings);
105
-
106
104
  const results = await Promise.all(
107
105
  roots.map(async root => {
108
106
  const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
@@ -114,16 +112,16 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
114
112
  return { root, result, warning };
115
113
  }),
116
114
  );
117
-
118
- for (const { root, result, warning } of results) {
115
+ for (const { result, warning } of results) {
119
116
  if (warning) warnings.push(warning);
120
- for (const skill of result.items) {
121
- if (root.plugin) skill.name = `${root.plugin}:${skill.name}`;
122
- items.push(skill);
123
- }
117
+ // Intentionally do NOT prefix skill names with `root.plugin`.
118
+ // The `plugin:name` format breaks skill:// URL parsing (colons are
119
+ // ambiguous with port separators) and is unintuitive for callers.
120
+ // Dedup-by-key in the capability layer already handles name collisions
121
+ // across providers using priority ordering.
122
+ items.push(...result.items);
124
123
  if (result.warnings) warnings.push(...result.warnings);
125
124
  }
126
-
127
125
  return { items, warnings };
128
126
  }
129
127
 
@@ -269,6 +269,29 @@ function readClaudeCommandToggles(): { enableUser: boolean; enableProject: boole
269
269
  }
270
270
  }
271
271
 
272
+ function getClaudeRelativeCommandName(commandsDir: string, filePath: string): string {
273
+ return path.relative(commandsDir, filePath).replace(/\.md$/, "");
274
+ }
275
+
276
+ function addClaudeCommandNamespaceAliases(commands: SlashCommand[], commandsDir: string): SlashCommand[] {
277
+ const rootCommands: SlashCommand[] = [];
278
+ const nestedCommands: SlashCommand[] = [];
279
+ const aliases: SlashCommand[] = [];
280
+
281
+ for (const command of commands) {
282
+ const relativeName = getClaudeRelativeCommandName(commandsDir, command.path);
283
+ if (!/[\\/]/.test(relativeName)) {
284
+ rootCommands.push(command);
285
+ continue;
286
+ }
287
+
288
+ nestedCommands.push(command);
289
+ aliases.push({ ...command, name: relativeName.replace(/[\\/]+/g, ":") });
290
+ }
291
+
292
+ return nestedCommands.length === 0 ? commands : [...rootCommands, ...nestedCommands, ...aliases];
293
+ }
294
+
272
295
  async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
273
296
  const items: SlashCommand[] = [];
274
297
  const warnings: string[] = [];
@@ -280,19 +303,17 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
280
303
 
281
304
  const userResult = await loadFilesFromDir<SlashCommand>(ctx, userCommandsDir, PROVIDER_ID, "user", {
282
305
  extensions: ["md"],
283
- transform: (name, content, path, source) => {
284
- const cmdName = name.replace(/\.md$/, "");
285
- return {
286
- name: cmdName,
287
- path,
288
- content,
289
- level: "user",
290
- _source: source,
291
- };
292
- },
306
+ recursive: true,
307
+ transform: (name, content, filePath, source) => ({
308
+ name: name.replace(/\.md$/, ""),
309
+ path: filePath,
310
+ content,
311
+ level: "user",
312
+ _source: source,
313
+ }),
293
314
  });
294
315
 
295
- items.push(...userResult.items);
316
+ items.push(...addClaudeCommandNamespaceAliases(userResult.items, userCommandsDir));
296
317
  if (userResult.warnings) warnings.push(...userResult.warnings);
297
318
  }
298
319
 
@@ -301,19 +322,17 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
301
322
 
302
323
  const projectResult = await loadFilesFromDir<SlashCommand>(ctx, projectCommandsDir, PROVIDER_ID, "project", {
303
324
  extensions: ["md"],
304
- transform: (name, content, path, source) => {
305
- const cmdName = name.replace(/\.md$/, "");
306
- return {
307
- name: cmdName,
308
- path,
309
- content,
310
- level: "project",
311
- _source: source,
312
- };
313
- },
325
+ recursive: true,
326
+ transform: (name, content, filePath, source) => ({
327
+ name: name.replace(/\.md$/, ""),
328
+ path: filePath,
329
+ content,
330
+ level: "project",
331
+ _source: source,
332
+ }),
314
333
  });
315
334
 
316
- items.push(...projectResult.items);
335
+ items.push(...addClaudeCommandNamespaceAliases(projectResult.items, projectCommandsDir));
317
336
  if (projectResult.warnings) warnings.push(...projectResult.warnings);
318
337
  }
319
338
 
package/src/edit/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type WritethroughDeferredHandle,
11
11
  writethroughNoop,
12
12
  } from "../lsp";
13
+ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
13
14
  import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
14
15
  import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
15
16
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
@@ -102,7 +103,16 @@ function createEditWritethrough(session: ToolSession): WritethroughCallback {
102
103
  const enableLsp = session.enableLsp ?? true;
103
104
  const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit");
104
105
  const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite");
105
- return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop;
106
+ const dedup = enableDiagnostics && session.settings.get("lsp.diagnosticsDeduplicate");
107
+ return enableLsp
108
+ ? createLspWritethrough(session.cwd, {
109
+ enableFormat,
110
+ enableDiagnostics,
111
+ transformDiagnostics: dedup
112
+ ? (path, result) => getDiagnosticsLedger(session).reduce(path, result)
113
+ : undefined,
114
+ })
115
+ : writethroughNoop;
106
116
  }
107
117
 
108
118
  /** Run apply_patch file operations and aggregate their multi-file result. */
@@ -294,6 +304,7 @@ export class EditTool implements AgentTool<TInput> {
294
304
  readonly #fuzzyThreshold: number;
295
305
  readonly #writethrough: WritethroughCallback;
296
306
  readonly #editMode?: EditMode;
307
+ readonly #dedupDiagnostics: boolean;
297
308
  readonly #pendingDeferredFetches = new Map<string, AbortController>();
298
309
 
299
310
  constructor(private readonly session: ToolSession) {
@@ -306,6 +317,10 @@ export class EditTool implements AgentTool<TInput> {
306
317
  this.#editMode = resolveConfiguredEditMode(envEditVariant);
307
318
  this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
308
319
  this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
320
+ this.#dedupDiagnostics =
321
+ (session.enableLsp ?? true) &&
322
+ session.settings.get("lsp.diagnosticsOnEdit") &&
323
+ session.settings.get("lsp.diagnosticsDeduplicate");
309
324
  this.#writethrough = createEditWritethrough(session);
310
325
  }
311
326
 
@@ -495,8 +510,13 @@ export class EditTool implements AgentTool<TInput> {
495
510
  }
496
511
 
497
512
  #injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult): void {
498
- const summary = diagnostics.summary ?? "";
499
- const lines = diagnostics.messages ?? [];
513
+ const effective = this.#dedupDiagnostics
514
+ ? getDiagnosticsLedger(this.session).reduce(path, diagnostics)
515
+ : diagnostics;
516
+ if (this.#dedupDiagnostics && effective.messages.length === 0) return;
517
+
518
+ const summary = effective.summary ?? "";
519
+ const lines = effective.messages ?? [];
500
520
  const body = [`Late LSP diagnostics for ${path} (arrived after the edit tool returned):`, summary, ...lines]
501
521
  .filter(Boolean)
502
522
  .join("\n");