@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.7

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 (137) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-1hmwwt45.md} +88 -0
  3. package/dist/cli.js +3156 -3163
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/stats-cli.d.ts +1 -6
  6. package/dist/types/commands/stats.d.ts +4 -0
  7. package/dist/types/config/model-discovery.d.ts +2 -0
  8. package/dist/types/config/model-registry.d.ts +6 -1
  9. package/dist/types/config/settings-schema.d.ts +10 -0
  10. package/dist/types/debug/report-bundle.d.ts +7 -2
  11. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +24 -6
  13. package/dist/types/extensibility/extensions/types.d.ts +60 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/launch/presence.d.ts +11 -0
  16. package/dist/types/lsp/client.d.ts +22 -0
  17. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  18. package/dist/types/mcp/config.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  20. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  21. package/dist/types/registry/agent-registry.d.ts +4 -0
  22. package/dist/types/session/agent-session-types.d.ts +0 -2
  23. package/dist/types/session/agent-session.d.ts +20 -0
  24. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  25. package/dist/types/session/irc-bridge.d.ts +2 -0
  26. package/dist/types/session/messages.d.ts +4 -0
  27. package/dist/types/session/session-loader.d.ts +18 -11
  28. package/dist/types/session/session-maintenance.d.ts +6 -2
  29. package/dist/types/session/session-manager.d.ts +19 -3
  30. package/dist/types/session/session-tools.d.ts +0 -1
  31. package/dist/types/session/turn-recovery.d.ts +7 -7
  32. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  33. package/dist/types/tools/browser/attach.d.ts +16 -0
  34. package/dist/types/tools/browser/launch.d.ts +2 -2
  35. package/dist/types/tools/browser.d.ts +1 -1
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  38. package/dist/types/tools/hub/jobs.d.ts +6 -0
  39. package/dist/types/tools/hub/types.d.ts +6 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/vibe/runtime.d.ts +3 -3
  43. package/package.json +13 -13
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/stats-cli.ts +6 -72
  47. package/src/commands/stats.ts +7 -4
  48. package/src/commit/analysis/conventional.ts +15 -9
  49. package/src/commit/analysis/summary.ts +15 -9
  50. package/src/commit/changelog/generate.ts +15 -9
  51. package/src/commit/map-reduce/map-phase.ts +3 -19
  52. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  53. package/src/config/model-discovery.ts +3 -3
  54. package/src/config/model-registry.ts +29 -16
  55. package/src/config/settings-schema.ts +13 -0
  56. package/src/debug/report-bundle.ts +25 -59
  57. package/src/discovery/claude-plugins.ts +2 -1
  58. package/src/discovery/claude.ts +4 -2
  59. package/src/discovery/helpers.ts +5 -0
  60. package/src/edit/hashline/filesystem.ts +9 -3
  61. package/src/edit/modes/patch.ts +31 -5
  62. package/src/edit/renderer.ts +48 -19
  63. package/src/extensibility/extensions/loader.ts +20 -5
  64. package/src/extensibility/extensions/runner.ts +239 -24
  65. package/src/extensibility/extensions/types.ts +62 -0
  66. package/src/extensibility/extensions/wrapper.ts +26 -11
  67. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  68. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  69. package/src/irc/bus.ts +1 -1
  70. package/src/launch/broker.ts +8 -1
  71. package/src/launch/presence.ts +77 -1
  72. package/src/lsp/client.ts +35 -3
  73. package/src/lsp/clients/biome-client.ts +47 -93
  74. package/src/lsp/servers.ts +17 -11
  75. package/src/lsp/tool.ts +2 -4
  76. package/src/lsp/workspace-diagnostics.ts +24 -2
  77. package/src/lsp/writethrough.ts +20 -10
  78. package/src/mcp/config.ts +50 -6
  79. package/src/memories/index.ts +29 -25
  80. package/src/mnemopi/backend.ts +13 -11
  81. package/src/modes/components/ask-dialog.ts +25 -5
  82. package/src/modes/components/tool-execution.ts +10 -6
  83. package/src/modes/components/welcome.ts +4 -1
  84. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  85. package/src/modes/controllers/selector-controller.ts +6 -0
  86. package/src/modes/controllers/tan-command-controller.ts +1 -0
  87. package/src/modes/interactive-mode.ts +49 -10
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  90. package/src/prompts/system/date-cwd-reminder.md +3 -0
  91. package/src/prompts/system/project-prompt.md +0 -1
  92. package/src/prompts/system/rewind-report.md +1 -3
  93. package/src/prompts/tools/browser.md +1 -0
  94. package/src/prompts/tools/rewind.md +1 -13
  95. package/src/registry/agent-lifecycle.ts +34 -0
  96. package/src/registry/agent-registry.ts +27 -2
  97. package/src/registry/persisted-agents.ts +40 -20
  98. package/src/sdk.ts +32 -2
  99. package/src/session/agent-session-types.ts +0 -2
  100. package/src/session/agent-session.ts +178 -46
  101. package/src/session/date-cwd-reminder.ts +77 -0
  102. package/src/session/irc-bridge.ts +5 -0
  103. package/src/session/messages.ts +5 -1
  104. package/src/session/session-loader.ts +106 -64
  105. package/src/session/session-maintenance.ts +189 -115
  106. package/src/session/session-manager.ts +142 -35
  107. package/src/session/session-persistence.ts +4 -4
  108. package/src/session/session-storage.ts +18 -5
  109. package/src/session/session-tools.ts +5 -10
  110. package/src/session/turn-recovery.ts +102 -20
  111. package/src/session/unexpected-stop-classifier.ts +33 -21
  112. package/src/slash-commands/builtin-session.ts +1 -1
  113. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  114. package/src/system-prompt.ts +0 -5
  115. package/src/task/executor.ts +4 -15
  116. package/src/task/persisted-revive.ts +3 -6
  117. package/src/tools/ask.ts +10 -3
  118. package/src/tools/browser/attach.ts +80 -25
  119. package/src/tools/browser/launch.ts +44 -15
  120. package/src/tools/browser/relay/daemon.ts +3 -7
  121. package/src/tools/browser/render.ts +1 -1
  122. package/src/tools/browser/shared-daemon.ts +3 -7
  123. package/src/tools/browser.ts +5 -5
  124. package/src/tools/builtin-names.ts +7 -3
  125. package/src/tools/checkpoint.ts +1 -7
  126. package/src/tools/file-write-fallback.ts +467 -0
  127. package/src/tools/hub/index.ts +1 -1
  128. package/src/tools/hub/jobs.ts +20 -3
  129. package/src/tools/hub/types.ts +6 -0
  130. package/src/tools/index.ts +1 -0
  131. package/src/tools/path-utils.ts +79 -0
  132. package/src/tools/render-utils.ts +53 -21
  133. package/src/tools/think.ts +15 -3
  134. package/src/tts/speech-enhancer.ts +19 -14
  135. package/src/utils/commit-message-generator.ts +14 -12
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
@@ -1,7 +1,7 @@
1
1
  import { type } from "@oh-my-pi/omptype";
2
2
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
3
3
  import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
4
- import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
4
+ import { completeSimple, retryTransientCompletion, validateToolCall } from "@oh-my-pi/pi-ai";
5
5
  import { prompt } from "@oh-my-pi/pi-utils";
6
6
  import summarySystemPrompt from "../../commit/prompts/summary-system.md" with { type: "text" };
7
7
  import summaryUserPrompt from "../../commit/prompts/summary-user.md" with { type: "text" };
@@ -52,16 +52,22 @@ export async function generateSummary({
52
52
  stat,
53
53
  });
54
54
 
55
- const response = await completeSimple(
56
- model,
57
- {
58
- systemPrompt: [systemPrompt],
59
- messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
60
- tools: [SummaryTool],
61
- },
62
- { apiKey, maxTokens: 200, reasoning: toReasoningEffort(thinkingLevel) },
55
+ const response = await retryTransientCompletion(() =>
56
+ completeSimple(
57
+ model,
58
+ {
59
+ systemPrompt: [systemPrompt],
60
+ messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
61
+ tools: [SummaryTool],
62
+ },
63
+ { apiKey, maxTokens: 200, reasoning: toReasoningEffort(thinkingLevel) },
64
+ ),
63
65
  );
64
66
 
67
+ if (response.stopReason === "error") {
68
+ throw new Error(response.errorMessage ?? "provider error");
69
+ }
70
+
65
71
  return parseSummaryFromResponse(response, commitType, scope);
66
72
  }
67
73
 
@@ -1,7 +1,7 @@
1
1
  import { type } from "@oh-my-pi/omptype";
2
2
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
3
3
  import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
4
- import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
4
+ import { completeSimple, retryTransientCompletion, validateToolCall } from "@oh-my-pi/pi-ai";
5
5
  import { prompt } from "@oh-my-pi/pi-utils";
6
6
  import changelogSystemPrompt from "../../commit/prompts/changelog-system.md" with { type: "text" };
7
7
  import changelogUserPrompt from "../../commit/prompts/changelog-user.md" with { type: "text" };
@@ -55,16 +55,22 @@ export async function generateChangelogEntries({
55
55
  stat,
56
56
  diff,
57
57
  });
58
- const response = await completeSimple(
59
- model,
60
- {
61
- systemPrompt: [prompt.render(changelogSystemPrompt)],
62
- messages: [{ role: "user", content: userContent, timestamp: Date.now() }],
63
- tools: [changelogTool],
64
- },
65
- { apiKey, maxTokens: 1200, reasoning: toReasoningEffort(thinkingLevel) },
58
+ const response = await retryTransientCompletion(() =>
59
+ completeSimple(
60
+ model,
61
+ {
62
+ systemPrompt: [prompt.render(changelogSystemPrompt)],
63
+ messages: [{ role: "user", content: userContent, timestamp: Date.now() }],
64
+ tools: [changelogTool],
65
+ },
66
+ { apiKey, maxTokens: 1200, reasoning: toReasoningEffort(thinkingLevel) },
67
+ ),
66
68
  );
67
69
 
70
+ if (response.stopReason === "error") {
71
+ throw new Error(response.errorMessage ?? "provider error");
72
+ }
73
+
68
74
  const parsed = parseChangelogResponse(response);
69
75
  return { entries: dedupeEntries(parsed.entries) };
70
76
  }
@@ -1,6 +1,6 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Api, ApiKey, AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai";
3
- import { completeSimple } from "@oh-my-pi/pi-ai";
3
+ import { completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import fileObserverSystemPrompt from "../../commit/prompts/file-observer-system.md" with { type: "text" };
6
6
  import fileObserverUserPrompt from "../../commit/prompts/file-observer-user.md" with { type: "text" };
@@ -66,7 +66,7 @@ export async function runMapPhase({
66
66
  messages: [{ role: "user", content: userContent, timestamp: Date.now() }] as Message[],
67
67
  };
68
68
 
69
- const response = await withRetry(
69
+ const response = await retryTransientCompletion(
70
70
  () =>
71
71
  completeSimple(model, request, {
72
72
  apiKey,
@@ -74,8 +74,7 @@ export async function runMapPhase({
74
74
  reasoning: toReasoningEffort(thinkingLevel),
75
75
  signal: AbortSignal.timeout(timeoutMs),
76
76
  }),
77
- maxRetries,
78
- retryBackoffMs,
77
+ { maxAttempts: maxRetries, baseDelayMs: retryBackoffMs },
79
78
  );
80
79
 
81
80
  const observations = parseObservations(response);
@@ -176,18 +175,3 @@ async function runWithConcurrency<T, R>(
176
175
  await Promise.all(runners);
177
176
  return results;
178
177
  }
179
-
180
- async function withRetry<T>(fn: () => Promise<T>, attempts: number, backoffMs: number): Promise<T> {
181
- let lastError: unknown;
182
- for (let attempt = 0; attempt < attempts; attempt += 1) {
183
- try {
184
- return await fn();
185
- } catch (error) {
186
- lastError = error;
187
- if (attempt < attempts - 1) {
188
- await Bun.sleep(backoffMs * (attempt + 1));
189
- }
190
- }
191
- }
192
- throw lastError;
193
- }
@@ -1,6 +1,6 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
3
- import { completeSimple } from "@oh-my-pi/pi-ai";
3
+ import { completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" };
6
6
  import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" };
@@ -35,15 +35,21 @@ export async function runReducePhase({
35
35
  stat,
36
36
  scope_candidates: scopeCandidates,
37
37
  });
38
- const response = await completeSimple(
39
- model,
40
- {
41
- systemPrompt: [prompt.render(reduceSystemPrompt)],
42
- messages: [{ role: "user", content: userContent, timestamp: Date.now() }],
43
- tools: [ReduceTool],
44
- },
45
- { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
38
+ const response = await retryTransientCompletion(() =>
39
+ completeSimple(
40
+ model,
41
+ {
42
+ systemPrompt: [prompt.render(reduceSystemPrompt)],
43
+ messages: [{ role: "user", content: userContent, timestamp: Date.now() }],
44
+ tools: [ReduceTool],
45
+ },
46
+ { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
47
+ ),
46
48
  );
47
49
 
50
+ if (response.stopReason === "error") {
51
+ throw new Error(response.errorMessage ?? "provider error");
52
+ }
53
+
48
54
  return parseConventionalAnalysisResponse(response, ReduceTool);
49
55
  }
@@ -645,7 +645,7 @@ export async function discoverLlamaCppModels(
645
645
  name: id,
646
646
  api: providerConfig.api,
647
647
  provider: providerConfig.provider,
648
- baseUrl,
648
+ baseUrl: ensureLlamaCppV1BaseUrl(baseUrl),
649
649
  reasoning: false,
650
650
  input: item.input ?? serverMetadata?.input ?? ["text"],
651
651
  imageInputDecoder: "stb",
@@ -1018,7 +1018,7 @@ export async function discoverProxyModels(
1018
1018
  return discovered;
1019
1019
  }
1020
1020
 
1021
- function normalizeLlamaCppBaseUrl(baseUrl?: string): string {
1021
+ export function normalizeLlamaCppBaseUrl(baseUrl?: string): string {
1022
1022
  const defaultBaseUrl = "http://127.0.0.1:8080";
1023
1023
  const raw = baseUrl || defaultBaseUrl;
1024
1024
  try {
@@ -1033,7 +1033,7 @@ function normalizeLlamaCppBaseUrl(baseUrl?: string): string {
1033
1033
  // ensureLlamaCppV1BaseUrl appends the OpenAI-compatible `/v1` prefix a
1034
1034
  // chat-completions request needs; native discovery keeps the bare root, which
1035
1035
  // serves `/models` and `/props` but not `/chat/completions`.
1036
- function ensureLlamaCppV1BaseUrl(baseUrl: string): string {
1036
+ export function ensureLlamaCppV1BaseUrl(baseUrl: string): string {
1037
1037
  return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
1038
1038
  }
1039
1039
 
@@ -61,9 +61,11 @@ import {
61
61
  type DiscoveryProviderConfig,
62
62
  discoverLlamaCppModelRuntimeMetadata,
63
63
  discoverModelsByProviderType,
64
+ ensureLlamaCppV1BaseUrl,
64
65
  getImplicitOllamaBaseUrl,
65
66
  getOllamaContextLengthOverride,
66
67
  normalizeLiteLLMDiscoveryBaseUrl,
68
+ normalizeLlamaCppBaseUrl,
67
69
  } from "./model-discovery";
68
70
  import {
69
71
  AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS,
@@ -577,7 +579,7 @@ export class ModelRegistry {
577
579
  const withConfigModels = this.#mergeCustomModels(resolvedDefaults, select(this.#customModelOverlays));
578
580
  const combined = this.#mergeCustomModels(withConfigModels, select(this.#runtimeModelOverlays));
579
581
  const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides);
580
- return this.#applyLlamaCppQwenThinkingToModels(this.#applyRuntimeProviderOverrides(withModelOverrides));
582
+ return this.#applyLlamaCppModelFixups(this.#applyRuntimeProviderOverrides(withModelOverrides));
581
583
  }
582
584
 
583
585
  #composeStaticModels(providerFilter?: ReadonlySet<string>): Model<Api>[] {
@@ -1064,9 +1066,7 @@ export class ModelRegistry {
1064
1066
  const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
1065
1067
  const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
1066
1068
  const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides);
1067
- this.#unprojectedModels = this.#applyLlamaCppQwenThinkingToModels(
1068
- this.#applyRuntimeProviderOverrides(withModelOverrides),
1069
- );
1069
+ this.#unprojectedModels = this.#applyLlamaCppModelFixups(this.#applyRuntimeProviderOverrides(withModelOverrides));
1070
1070
  this.#models = this.#applyRuntimeModelModifiers(this.#unprojectedModels);
1071
1071
  }
1072
1072
 
@@ -1446,20 +1446,28 @@ export class ModelRegistry {
1446
1446
  });
1447
1447
  }
1448
1448
 
1449
- // #applyLlamaCppQwenThinkingToModels re-runs applyLlamaCppQwenThinking as the
1450
- // outermost transform for llama.cpp-provider models, after discovery merges,
1451
- // cache fallbacks, and provider/transport overrides have run. It is
1452
- // idempotent, so it restores the routed Qwen model's chat-completions api,
1453
- // `/v1` runtime base URL, and disable dialect even when a configured `baseUrl`
1454
- // override (which wins in mergeDiscoveredModel) or a fallback to a pre-fix
1455
- // cached row would otherwise leave the old spec in place.
1456
- #applyLlamaCppQwenThinkingToModels(models: Model<Api>[]): Model<Api>[] {
1449
+ // #applyLlamaCppModelFixups is the outermost transform for llama.cpp-provider
1450
+ // models, after discovery merges, cache fallbacks, and provider/transport
1451
+ // overrides have run. It applies Qwen-specific fixes (api, reasoning, compat)
1452
+ // and ensures all non-transport models have the `/v1` prefix in their baseUrl,
1453
+ // even when a configured override or stale cache row would strip it.
1454
+ #applyLlamaCppModelFixups(models: Model<Api>[]): Model<Api>[] {
1457
1455
  const llamaCppProviders = new Set<string>();
1458
1456
  for (const provider of this.#discoverableProviders) {
1459
1457
  if (provider.discovery.type === "llama.cpp") llamaCppProviders.add(provider.provider);
1460
1458
  }
1461
1459
  if (llamaCppProviders.size === 0) return models;
1462
- return models.map(model => (llamaCppProviders.has(model.provider) ? applyLlamaCppQwenThinking(model) : model));
1460
+ return models.map(model => {
1461
+ if (!llamaCppProviders.has(model.provider)) return model;
1462
+ const withFixups = applyLlamaCppQwenThinking(model);
1463
+ if (!withFixups.transport && !withFixups.baseUrl.endsWith("/v1")) {
1464
+ return buildModel({
1465
+ ...withFixups,
1466
+ baseUrl: ensureLlamaCppV1BaseUrl(normalizeLlamaCppBaseUrl(withFixups.baseUrl)),
1467
+ });
1468
+ }
1469
+ return withFixups;
1470
+ });
1463
1471
  }
1464
1472
 
1465
1473
  #mergeProviderOverride(baseOverride: ProviderOverride | undefined, override: ProviderOverride): ProviderOverride {
@@ -1667,17 +1675,22 @@ export class ModelRegistry {
1667
1675
  *
1668
1676
  * Side-effect-free and synchronous: a command-backed key (`!cmd`) counts as
1669
1677
  * configured by its presence alone — the program is NOT executed — and OAuth
1670
- * tokens are NOT refreshed (`authStorage.hasAuth`). This is what keeps the
1678
+ * tokens are NOT refreshed (`authStorage.hasResolvableAuth`). This is what keeps the
1671
1679
  * model-switch pre-flight off the event loop's hot path; the real key
1672
1680
  * (command execution + OAuth refresh) is resolved lazily per request via
1673
1681
  * {@link ModelRegistry.resolver}.
1682
+ *
1683
+ * Cross-provider env aliases count here (`xai-oauth` can borrow `XAI_API_KEY`)
1684
+ * so an explicit `xai-oauth/…` selector does not fail with "No API key".
1685
+ * Default-model availability still uses {@link AuthStorage.hasAuth}, which
1686
+ * ignores that alias so SuperGrok is not auto-selected from a paid key.
1674
1687
  */
1675
1688
  hasConfiguredAuth(model: Model<Api>): boolean {
1676
1689
  const keyConfig = this.#customProviderApiKeys.get(model.provider);
1677
1690
  return (
1678
1691
  isCommandConfigValue(keyConfig) ||
1679
1692
  this.#keylessProviders.has(model.provider) ||
1680
- this.authStorage.hasAuth(model.provider)
1693
+ this.authStorage.hasResolvableAuth(model.provider)
1681
1694
  );
1682
1695
  }
1683
1696
 
@@ -2097,7 +2110,7 @@ export class ModelRegistry {
2097
2110
  transportOverride,
2098
2111
  );
2099
2112
  this.#runtimeProviderOverrides.set(providerName, nextRuntimeOverride);
2100
- this.#unprojectedModels = this.#applyLlamaCppQwenThinkingToModels(
2113
+ this.#unprojectedModels = this.#applyLlamaCppModelFixups(
2101
2114
  this.#unprojectedModels.map(model => {
2102
2115
  if (model.provider !== providerName) return model;
2103
2116
  return this.#applyProviderTransportOverrideToModel(model, transportOverride);
@@ -152,6 +152,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
152
152
  "Output Limits",
153
153
  "Execution",
154
154
  "Discovery & MCP",
155
+ "Extensions",
155
156
  "Developer",
156
157
  ],
157
158
  tasks: ["Modes", "Subagents", "Isolation", "Commands & Skills"],
@@ -5494,6 +5495,18 @@ export const SETTINGS_SCHEMA = {
5494
5495
 
5495
5496
  "commit.changelogMaxDiffChars": { type: "number", default: 120000 },
5496
5497
 
5498
+ "extensionHandlers.toolCallTimeoutMs": {
5499
+ type: "number",
5500
+ default: 30_000,
5501
+ ui: {
5502
+ tab: "tools",
5503
+ group: "Extensions",
5504
+ label: "Tool Call Handler Timeout (ms)",
5505
+ description:
5506
+ "Positive finite active-work timeout for extension tool_call handlers; invalid values use 30000ms, and time awaiting OMP-owned dialogs does not count",
5507
+ },
5508
+ },
5509
+
5497
5510
  "dev.autoqa": {
5498
5511
  type: "boolean",
5499
5512
  default: true,
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Creates a .tar.gz archive with session data, logs, system info, and optional profiling data.
5
5
  */
6
+
7
+ import type { Dirent } from "node:fs";
6
8
  import * as fs from "node:fs/promises";
7
9
  import * as path from "node:path";
8
10
  import type { WorkProfile } from "@oh-my-pi/pi-natives";
@@ -66,8 +68,8 @@ export interface DebugLogSource {
66
68
  *
67
69
  * Bundle contents:
68
70
  * - session.jsonl: Current session transcript
69
- * - artifacts/: Session artifacts directory
70
- * - subagents/: Subagent sessions + artifacts
71
+ * - artifacts/: Current session's artifacts subtree (recursive), including any
72
+ * subagent session transcripts nested under it
71
73
  * - logs.txt: Recent log entries
72
74
  * - system.json: OS, arch, CPU, memory, versions
73
75
  * - env.json: Sanitized environment variables
@@ -130,14 +132,12 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
130
132
  // Session file might not exist yet
131
133
  }
132
134
 
133
- // Artifacts directory (same path without .jsonl)
135
+ // Artifacts subtree (same path without .jsonl). Recursing captures the
136
+ // current session's nested subagent transcripts and their artifacts while
137
+ // staying inside this session's own directory — unrelated co-located
138
+ // sessions in the sessions root are never touched (#8648).
134
139
  const artifactsDir = options.sessionFile.slice(0, -6);
135
140
  await addDirectoryToArchive(data, files, artifactsDir, "artifacts");
136
-
137
- // Look for subagent sessions in the same directory
138
- const sessionDir = path.dirname(options.sessionFile);
139
- const sessionBasename = path.basename(options.sessionFile, ".jsonl");
140
- await addSubagentSessions(data, files, sessionDir, sessionBasename);
141
141
  }
142
142
 
143
143
  // CPU profile
@@ -172,68 +172,34 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
172
172
  return { path: outputPath, files };
173
173
  }
174
174
 
175
- /** Add all files from a directory to the archive */
175
+ /** Recursively add every file under a directory to the archive. */
176
176
  async function addDirectoryToArchive(
177
177
  data: Record<string, string>,
178
178
  files: string[],
179
179
  dirPath: string,
180
180
  archivePrefix: string,
181
181
  ): Promise<void> {
182
+ let entries: Dirent[];
182
183
  try {
183
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
184
- for (const entry of entries) {
185
- if (!entry.isFile()) continue;
186
- const filePath = path.join(dirPath, entry.name);
187
- const archivePath = `${archivePrefix}/${entry.name}`;
188
- try {
189
- const content = await Bun.file(filePath).text();
190
- data[archivePath] = content;
191
- files.push(archivePath);
192
- } catch {
193
- // Skip files we can't read
194
- }
195
- }
184
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
196
185
  } catch {
197
186
  // Directory doesn't exist
187
+ return;
198
188
  }
199
- }
200
-
201
- /** Find and add subagent session files */
202
- async function addSubagentSessions(
203
- data: Record<string, string>,
204
- files: string[],
205
- sessionDir: string,
206
- parentBasename: string,
207
- ): Promise<void> {
208
- // Subagent sessions are named with task IDs in the same directory
209
- // They follow the pattern: {timestamp}_{sessionId}.jsonl
210
- // We look for any sessions created after the parent session
211
- try {
212
- const entries = await fs.readdir(sessionDir, { withFileTypes: true });
213
- const sessionFiles = entries
214
- .filter(e => e.isFile() && e.name.endsWith(".jsonl") && e.name !== `${parentBasename}.jsonl`)
215
- .map(e => e.name);
216
-
217
- // Limit to most recent 10 subagent sessions
218
- const sortedFiles = sessionFiles.sort().slice(-10);
219
-
220
- for (const filename of sortedFiles) {
221
- const filePath = path.join(sessionDir, filename);
222
- const archivePath = `subagents/${filename}`;
223
- try {
224
- const content = await Bun.file(filePath).text();
225
- data[archivePath] = content;
226
- files.push(archivePath);
227
-
228
- // Also add artifacts for this subagent session
229
- const artifactsDir = filePath.slice(0, -6);
230
- await addDirectoryToArchive(data, files, artifactsDir, `subagents/${filename.slice(0, -6)}`);
231
- } catch {
232
- // Skip files we can't read
233
- }
189
+ for (const entry of entries) {
190
+ const entryPath = path.join(dirPath, entry.name);
191
+ const archivePath = `${archivePrefix}/${entry.name}`;
192
+ if (entry.isDirectory()) {
193
+ await addDirectoryToArchive(data, files, entryPath, archivePath);
194
+ continue;
195
+ }
196
+ if (!entry.isFile()) continue;
197
+ try {
198
+ data[archivePath] = await Bun.file(entryPath).text();
199
+ files.push(archivePath);
200
+ } catch {
201
+ // Skip files we can't read
234
202
  }
235
- } catch {
236
- // Directory doesn't exist
237
203
  }
238
204
  }
239
205
 
@@ -379,8 +379,9 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
379
379
  roots.map(async root => {
380
380
  const toolsDir = path.join(root.path, "tools");
381
381
  return loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, root.scope, {
382
+ extensions: ["ts", "js"],
382
383
  transform: (name, _content, filePath, source) => {
383
- const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
384
+ const toolName = name.replace(/\.(ts|js)$/, "");
384
385
  return {
385
386
  name: toolName,
386
387
  path: filePath,
@@ -406,8 +406,9 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
406
406
  const userToolsDir = path.join(userBase, "tools");
407
407
 
408
408
  const userResult = await loadFilesFromDir<CustomTool>(ctx, userToolsDir, PROVIDER_ID, "user", {
409
+ extensions: ["ts", "js"],
409
410
  transform: (name, _content, path, source) => {
410
- const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
411
+ const toolName = name.replace(/\.(ts|js)$/, "");
411
412
  return {
412
413
  name: toolName,
413
414
  path,
@@ -425,8 +426,9 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
425
426
  const projectToolsDir = path.join(projectBase, "tools");
426
427
 
427
428
  const projectResult = await loadFilesFromDir<CustomTool>(ctx, projectToolsDir, PROVIDER_ID, "project", {
429
+ extensions: ["ts", "js"],
428
430
  transform: (name, _content, path, source) => {
429
- const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
431
+ const toolName = name.replace(/\.(ts|js)$/, "");
430
432
  return {
431
433
  name: toolName,
432
434
  path,
@@ -512,6 +512,11 @@ export async function loadFilesFromDir<T>(
512
512
  gitignore: true,
513
513
  hidden: false,
514
514
  fileType: FileType.File,
515
+ // Thread the caller's non-recursive intent explicitly: the native glob
516
+ // defaults `recursive` to true and rewrites `*.{ts,js}` -> `**/*.{ts,js}`,
517
+ // which would walk the entire subtree (e.g. a venv's site-packages under
518
+ // ~/.codex/tools) and import arbitrary frontend assets as tools (#8552).
519
+ recursive,
515
520
  });
516
521
  matches = result.matches;
517
522
  } catch {
@@ -25,6 +25,7 @@ import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client";
25
25
  import type { ToolSession } from "../../tools";
26
26
  import { routeWriteThroughBridge } from "../../tools/acp-bridge";
27
27
  import { assertEditableFileContent } from "../../tools/auto-generated-guard";
28
+ import { deleteFileWithFallback, writeFileWithFallback } from "../../tools/file-write-fallback";
28
29
  import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
29
30
  import { isInternalUrlPath } from "../../tools/path-utils";
30
31
  import { enforcePlanModeWrite, resolvePlanPath, targetsLocalSandbox } from "../../tools/plan-mode-guard";
@@ -153,7 +154,7 @@ export class HashlineFilesystem extends Filesystem {
153
154
  enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
154
155
  const absolutePath = this.resolveAbsolute(relativePath);
155
156
  try {
156
- await fs.rm(absolutePath);
157
+ await deleteFileWithFallback(absolutePath);
157
158
  } catch (error) {
158
159
  if (isEnoent(error)) throw new NotFoundError(relativePath, error);
159
160
  throw error;
@@ -173,8 +174,13 @@ export class HashlineFilesystem extends Filesystem {
173
174
  const fromAbsolute = this.resolveAbsolute(fromRelative);
174
175
  const toAbsolute = this.resolveAbsolute(toRelative);
175
176
  if (content !== undefined) {
176
- await Bun.write(toAbsolute, content);
177
- await fs.rm(fromAbsolute);
177
+ // The one `edit` write that does not pass through the writethrough, so it
178
+ // routes to the fallback seam directly. `patcher.ts` always supplies
179
+ // `content` for a hashline `MV`, making this the live branch. The source
180
+ // unlink is a separate primitive with its own seam, so a move out of the
181
+ // workspace and a move out of denied territory both complete.
182
+ await writeFileWithFallback(toAbsolute, content);
183
+ await deleteFileWithFallback(fromAbsolute);
178
184
  } else {
179
185
  await fs.rename(fromAbsolute, toAbsolute);
180
186
  }
@@ -20,6 +20,12 @@ import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client";
20
20
  import type { ToolSession } from "../../tools";
21
21
  import { routeWriteThroughBridge } from "../../tools/acp-bridge";
22
22
  import { assertEditableFile } from "../../tools/auto-generated-guard";
23
+ import {
24
+ deleteFileWithFallback,
25
+ hasFileWriteFallback,
26
+ isPermissionDeniedError,
27
+ writeFileWithFallback,
28
+ } from "../../tools/file-write-fallback";
23
29
  import {
24
30
  invalidateFsScanAfterDelete,
25
31
  invalidateFsScanAfterRename,
@@ -111,6 +117,26 @@ export interface ApplyPatchOptions {
111
117
  // Default File System
112
118
  // ═══════════════════════════════════════════════════════════════════════════
113
119
 
120
+ /**
121
+ * Create a patch target's parent directory, tolerating a permission denial when a
122
+ * file-write fallback is registered.
123
+ *
124
+ * `apply_patch` mkdirs the parent before writing, so under a sandbox that denies
125
+ * the out-of-tree path this throws before the write — and therefore before
126
+ * {@link writeFileWithFallback} — is ever reached, leaving the fallback unable to
127
+ * broker a `create` or a rename-move into a new directory. Swallowing only a
128
+ * permission denial, and only with a handler installed, hands control to the write,
129
+ * which reports the denial through the seam. Without a handler the error propagates
130
+ * exactly as before.
131
+ */
132
+ async function mkdirAllowingFallback(dir: string): Promise<void> {
133
+ try {
134
+ await fs.promises.mkdir(dir, { recursive: true });
135
+ } catch (error) {
136
+ if (!hasFileWriteFallback() || !isPermissionDeniedError(error)) throw error;
137
+ }
138
+ }
139
+
114
140
  /** Default filesystem implementation using Bun APIs */
115
141
  export const defaultFileSystem: FileSystem = {
116
142
  async exists(path: string): Promise<boolean> {
@@ -123,13 +149,13 @@ export const defaultFileSystem: FileSystem = {
123
149
  return fs.promises.readFile(path);
124
150
  },
125
151
  async write(path: string, content: string): Promise<void> {
126
- await Bun.write(path, await serializeEditFileText(path, path, content));
152
+ await writeFileWithFallback(path, await serializeEditFileText(path, path, content));
127
153
  },
128
154
  async delete(path: string): Promise<void> {
129
- await fs.promises.unlink(path);
155
+ await deleteFileWithFallback(path);
130
156
  },
131
157
  async mkdir(path: string): Promise<void> {
132
- await fs.promises.mkdir(path, { recursive: true });
158
+ await mkdirAllowingFallback(path);
133
159
  },
134
160
  };
135
161
 
@@ -1753,7 +1779,7 @@ class LspFileSystem implements FileSystem {
1753
1779
  }
1754
1780
 
1755
1781
  async delete(path: string): Promise<void> {
1756
- await this.#getFile(path).unlink();
1782
+ await deleteFileWithFallback(path, this.#getFile(path));
1757
1783
  if (this.session.enableLsp ?? true) {
1758
1784
  await notifyWorkspaceWatchedFiles(
1759
1785
  this.session.cwd,
@@ -1764,7 +1790,7 @@ class LspFileSystem implements FileSystem {
1764
1790
  }
1765
1791
 
1766
1792
  async mkdir(path: string): Promise<void> {
1767
- await fs.promises.mkdir(path, { recursive: true });
1793
+ await mkdirAllowingFallback(path);
1768
1794
  }
1769
1795
 
1770
1796
  getDiagnostics(): FileDiagnosticsResult | undefined {