@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.2

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 (198) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3556 -3504
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +10 -0
  14. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  15. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  16. package/dist/types/eval/backend.d.ts +3 -3
  17. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  18. package/dist/types/eval/js/context-manager.d.ts +5 -3
  19. package/dist/types/eval/js/process-entry.d.ts +6 -0
  20. package/dist/types/eval/js/worker-core.d.ts +15 -1
  21. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  22. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  23. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  26. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  27. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  28. package/dist/types/goals/guided-setup.d.ts +12 -0
  29. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  30. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  31. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  32. package/dist/types/main.d.ts +1 -0
  33. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  34. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  35. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  36. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  37. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  38. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  39. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  40. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  41. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  42. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  43. package/dist/types/modes/interactive-mode.d.ts +1 -0
  44. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  45. package/dist/types/modes/orchestrate.d.ts +1 -1
  46. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  47. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  48. package/dist/types/modes/types.d.ts +1 -0
  49. package/dist/types/modes/ultrathink.d.ts +1 -1
  50. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  51. package/dist/types/modes/workflow.d.ts +1 -1
  52. package/dist/types/session/agent-session.d.ts +6 -0
  53. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  54. package/dist/types/session/messages.d.ts +15 -0
  55. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  56. package/dist/types/subprocess/worker-client.d.ts +6 -0
  57. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  58. package/dist/types/tools/grep.d.ts +0 -2
  59. package/dist/types/tools/read.d.ts +0 -4
  60. package/dist/types/web/search/provider.d.ts +10 -3
  61. package/dist/types/web/search/providers/codex.d.ts +5 -4
  62. package/package.json +12 -12
  63. package/src/advisor/__tests__/advisor.test.ts +917 -42
  64. package/src/advisor/advise-tool.ts +17 -1
  65. package/src/advisor/runtime.ts +270 -67
  66. package/src/autolearn/controller.ts +15 -3
  67. package/src/autoresearch/dashboard.ts +2 -2
  68. package/src/cli/args.ts +12 -0
  69. package/src/cli/auth-broker-cli.ts +30 -11
  70. package/src/cli/auth-gateway-cli.ts +5 -1
  71. package/src/cli/config-cli.ts +15 -3
  72. package/src/cli/dry-balance-cli.ts +14 -4
  73. package/src/cli/flag-tables.ts +21 -7
  74. package/src/cli/update-cli.ts +62 -11
  75. package/src/cli/usage-cli.ts +58 -5
  76. package/src/cli/usage-error.ts +7 -0
  77. package/src/cli.ts +23 -1
  78. package/src/commands/acp.ts +11 -2
  79. package/src/commands/launch.ts +12 -3
  80. package/src/commands/token.ts +3 -1
  81. package/src/config/api-key-resolver.ts +12 -3
  82. package/src/config/config-file.ts +30 -12
  83. package/src/config/model-registry.ts +7 -7
  84. package/src/config/model-resolver.ts +21 -7
  85. package/src/config/models-config.ts +1 -1
  86. package/src/config/settings-schema.ts +10 -0
  87. package/src/cursor.ts +2 -0
  88. package/src/discovery/claude-plugins.ts +9 -3
  89. package/src/discovery/omp-plugins.ts +6 -2
  90. package/src/discovery/substitute-plugin-root.ts +32 -0
  91. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  92. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  93. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  94. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  95. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  96. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  97. package/src/eval/agent-bridge.ts +121 -116
  98. package/src/eval/backend.ts +3 -3
  99. package/src/eval/bridge-timeout.ts +20 -2
  100. package/src/eval/executor-base.ts +85 -7
  101. package/src/eval/jl/kernel.ts +2 -1
  102. package/src/eval/js/context-manager.ts +109 -32
  103. package/src/eval/js/process-entry.ts +27 -0
  104. package/src/eval/js/shared/runtime.ts +1 -1
  105. package/src/eval/js/worker-core.ts +70 -9
  106. package/src/eval/js/worker-entry.ts +1 -1
  107. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  108. package/src/eval/py/kernel.ts +2 -1
  109. package/src/eval/py/prelude.py +28 -1
  110. package/src/eval/py/spawn-options.ts +13 -0
  111. package/src/eval/py/tool-bridge.ts +13 -14
  112. package/src/eval/rb/kernel.ts +2 -1
  113. package/src/exec/bash-executor.ts +30 -43
  114. package/src/extensibility/custom-tools/types.ts +3 -0
  115. package/src/extensibility/extensions/runner.ts +3 -0
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/extensions/wrapper.ts +18 -18
  118. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  119. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  120. package/src/extensibility/plugins/manager.ts +28 -7
  121. package/src/goals/guided-setup.ts +29 -1
  122. package/src/internal-urls/history-protocol.ts +95 -15
  123. package/src/internal-urls/memory-protocol.ts +13 -9
  124. package/src/internal-urls/registry-helpers.ts +50 -1
  125. package/src/launch/broker.ts +38 -25
  126. package/src/lsp/client.ts +7 -1
  127. package/src/main.ts +29 -0
  128. package/src/mcp/oauth-discovery.ts +20 -1
  129. package/src/mcp/oauth-flow.ts +3 -1
  130. package/src/mcp/tool-bridge.ts +57 -6
  131. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  132. package/src/modes/components/agent-dashboard.ts +2 -2
  133. package/src/modes/components/agent-hub.ts +15 -2
  134. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  135. package/src/modes/components/chat-transcript-builder.ts +26 -4
  136. package/src/modes/components/dynamic-border.ts +9 -6
  137. package/src/modes/components/extensions/extension-list.ts +2 -2
  138. package/src/modes/components/hook-selector.ts +10 -4
  139. package/src/modes/components/login-dialog.ts +5 -0
  140. package/src/modes/components/mcp-add-wizard.ts +5 -0
  141. package/src/modes/components/plan-review-overlay.ts +11 -11
  142. package/src/modes/components/read-tool-group.ts +1 -8
  143. package/src/modes/components/status-line/component.ts +10 -1
  144. package/src/modes/components/transcript-container.ts +110 -7
  145. package/src/modes/controllers/command-controller.ts +12 -4
  146. package/src/modes/controllers/event-controller.ts +80 -15
  147. package/src/modes/controllers/input-controller.ts +4 -2
  148. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  149. package/src/modes/controllers/selector-controller.ts +24 -5
  150. package/src/modes/controllers/todo-command-controller.ts +18 -14
  151. package/src/modes/interactive-mode.ts +7 -3
  152. package/src/modes/magic-keyword-boundary.ts +23 -0
  153. package/src/modes/orchestrate.ts +6 -5
  154. package/src/modes/print-mode.ts +9 -0
  155. package/src/modes/prompt-action-autocomplete.ts +6 -1
  156. package/src/modes/rpc/host-tools.ts +15 -0
  157. package/src/modes/rpc/rpc-mode.ts +123 -48
  158. package/src/modes/types.ts +1 -1
  159. package/src/modes/ultrathink.ts +6 -5
  160. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  161. package/src/modes/utils/ui-helpers.ts +27 -1
  162. package/src/modes/workflow.ts +6 -5
  163. package/src/prompts/advisor/system.md +1 -0
  164. package/src/prompts/system/system-prompt.md +1 -0
  165. package/src/prompts/tools/eval.md +2 -2
  166. package/src/prompts/tools/grep.md +1 -2
  167. package/src/prompts/tools/read.md +2 -4
  168. package/src/sdk.ts +61 -34
  169. package/src/session/agent-session.ts +283 -39
  170. package/src/session/exit-diagnostics.ts +108 -0
  171. package/src/session/messages.test.ts +66 -0
  172. package/src/session/messages.ts +37 -0
  173. package/src/session/streaming-output.ts +40 -12
  174. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  175. package/src/slash-commands/helpers/logout.ts +23 -3
  176. package/src/slash-commands/helpers/usage-report.ts +14 -2
  177. package/src/subprocess/worker-client.ts +9 -2
  178. package/src/system-prompt.test.ts +36 -0
  179. package/src/system-prompt.ts +1 -1
  180. package/src/task/executor.ts +8 -0
  181. package/src/task/render.test.ts +36 -0
  182. package/src/task/render.ts +55 -43
  183. package/src/tools/bash-skill-urls.ts +4 -1
  184. package/src/tools/bash.ts +1 -0
  185. package/src/tools/browser/registry.ts +17 -3
  186. package/src/tools/eval.ts +14 -9
  187. package/src/tools/gh.ts +3 -1
  188. package/src/tools/grep.ts +5 -45
  189. package/src/tools/path-utils.ts +7 -1
  190. package/src/tools/read.ts +23 -74
  191. package/src/tools/write.ts +82 -9
  192. package/src/tools/yield.ts +29 -1
  193. package/src/utils/title-generator.ts +10 -6
  194. package/src/web/search/index.ts +39 -22
  195. package/src/web/search/provider.ts +33 -16
  196. package/src/web/search/providers/codex.ts +68 -21
  197. package/src/web/search/providers/perplexity-auth.ts +20 -11
  198. package/src/web/search/providers/perplexity.ts +14 -2
@@ -11,9 +11,9 @@ interface ConfigSchemaError {
11
11
  }
12
12
 
13
13
  /**
14
- * Module-private cache of (jsonPath, ymlPath) pairs we already migrated this
15
- * process. Prevents `ConfigFile.relocate()` / repeated `tryLoad()` calls from
16
- * re-running the migration over and over on the boot path.
14
+ * Module-private cache of JSON YAML migrations this process already ran.
15
+ * Prevents `ConfigFile.relocate()` / repeated `tryLoad()` calls from re-running
16
+ * the migration over and over on the boot path.
17
17
  */
18
18
  const migratedPaths = new Set<string>();
19
19
 
@@ -123,6 +123,7 @@ export type LoadResult<T> =
123
123
 
124
124
  export class ConfigFile<T> implements IConfigFile<T> {
125
125
  readonly #basePath: string;
126
+ readonly #yamlFallbackPath: string | null;
126
127
  readonly #jsonMigrationPath: string | null;
127
128
  #cache?: LoadResult<T>;
128
129
  #auxValidate?: (value: T) => void;
@@ -134,13 +135,17 @@ export class ConfigFile<T> implements IConfigFile<T> {
134
135
  ) {
135
136
  this.#basePath = configPath;
136
137
  if (configPath.endsWith(".yml")) {
138
+ this.#yamlFallbackPath = `${configPath.slice(0, -4)}.yaml`;
137
139
  this.#jsonMigrationPath = `${configPath.slice(0, -4)}.json`;
138
140
  } else if (configPath.endsWith(".yaml")) {
141
+ this.#yamlFallbackPath = null;
139
142
  this.#jsonMigrationPath = `${configPath.slice(0, -5)}.json`;
140
143
  } else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
144
+ this.#yamlFallbackPath = null;
141
145
  // JSON configs are still supported without migration.
142
146
  this.#jsonMigrationPath = null;
143
147
  } else {
148
+ this.#yamlFallbackPath = null;
144
149
  throw new Error(`Invalid config file path: ${configPath}`);
145
150
  }
146
151
  }
@@ -150,9 +155,11 @@ export class ConfigFile<T> implements IConfigFile<T> {
150
155
  * Sync callers (tests, settings init) hit this implicitly via {@link tryLoad}.
151
156
  */
152
157
  #ensureMigrated(): void {
153
- if (this.#jsonMigrationPath) {
154
- migrateJsonToYml(this.#jsonMigrationPath, this.#basePath);
158
+ if (!this.#jsonMigrationPath) return;
159
+ if (this.#yamlFallbackPath && !fs.existsSync(this.#basePath) && fs.existsSync(this.#yamlFallbackPath)) {
160
+ return;
155
161
  }
162
+ migrateJsonToYml(this.#jsonMigrationPath, this.#basePath);
156
163
  }
157
164
 
158
165
  relocate(configPath?: string): ConfigFile<T> {
@@ -163,9 +170,19 @@ export class ConfigFile<T> implements IConfigFile<T> {
163
170
  return result;
164
171
  }
165
172
 
173
+ #resolveReadPath(): string {
174
+ if (fs.existsSync(this.#basePath)) {
175
+ return this.#basePath;
176
+ }
177
+ if (this.#yamlFallbackPath && fs.existsSync(this.#yamlFallbackPath)) {
178
+ return this.#yamlFallbackPath;
179
+ }
180
+ return this.#basePath;
181
+ }
182
+
166
183
  getMtimeMs(): number | null {
167
184
  try {
168
- return fs.statSync(this.path()).mtimeMs;
185
+ return fs.statSync(this.#resolveReadPath()).mtimeMs;
169
186
  } catch (err) {
170
187
  if (isEnoent(err)) return null;
171
188
  throw err;
@@ -211,12 +228,13 @@ export class ConfigFile<T> implements IConfigFile<T> {
211
228
  #parseContent(content: string): LoadResult<T> {
212
229
  try {
213
230
  let parsed: unknown;
214
- if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) {
231
+ const readPath = this.#resolveReadPath();
232
+ if (readPath.endsWith(".json") || readPath.endsWith(".jsonc")) {
215
233
  parsed = JSONC.parse(content);
216
- } else if (this.#basePath.endsWith(".yml") || this.#basePath.endsWith(".yaml")) {
234
+ } else if (readPath.endsWith(".yml") || readPath.endsWith(".yaml")) {
217
235
  parsed = YAML.parse(content);
218
236
  } else {
219
- throw new Error(`Invalid config file path: ${this.#basePath}`);
237
+ throw new Error(`Invalid config file path: ${readPath}`);
220
238
  }
221
239
 
222
240
  const checked = this.schema(parsed);
@@ -255,7 +273,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
255
273
 
256
274
  let content: string;
257
275
  try {
258
- content = fs.readFileSync(this.path(), "utf-8").trim();
276
+ content = fs.readFileSync(this.#resolveReadPath(), "utf-8").trim();
259
277
  } catch (error) {
260
278
  if (isEnoent(error)) {
261
279
  return this.#storeCache({ status: "not-found" });
@@ -275,7 +293,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
275
293
 
276
294
  let content: string;
277
295
  try {
278
- content = (await Bun.file(this.path()).text()).trim();
296
+ content = (await Bun.file(this.#resolveReadPath()).text()).trim();
279
297
  } catch (error) {
280
298
  if (isEnoent(error)) {
281
299
  return this.#storeCache({ status: "not-found" });
@@ -306,7 +324,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
306
324
  }
307
325
 
308
326
  path(): string {
309
- return this.#basePath;
327
+ return this.#resolveReadPath();
310
328
  }
311
329
 
312
330
  invalidate() {
@@ -258,7 +258,7 @@ export interface ProviderDiscoveryState {
258
258
  error?: string;
259
259
  }
260
260
 
261
- /** Result of loading custom models from models.json */
261
+ /** Result of loading custom models config. */
262
262
  interface CustomModelsResult {
263
263
  models?: CustomModelOverlay[];
264
264
  overrides?: Map<string, ProviderOverride>;
@@ -309,7 +309,7 @@ interface CommandApiKeyResolution {
309
309
  value?: string;
310
310
  }
311
311
  /**
312
- * Resolve a models.yml secret/config value to an actual value.
312
+ * Resolve a models.yml/models.yaml secret/config value to an actual value.
313
313
  * `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are
314
314
  * checked first and the input falls back to a literal value.
315
315
  */
@@ -822,7 +822,7 @@ export class ModelRegistry {
822
822
  }
823
823
 
824
824
  /**
825
- * Reload models from disk (built-in + custom from models.json).
825
+ * Reload models from disk (built-in + custom config).
826
826
  */
827
827
  async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
828
828
  this.#reloadStaticModels();
@@ -938,7 +938,7 @@ export class ModelRegistry {
938
938
  #reloadStaticModels(): void {
939
939
  const currentMtime = this.#modelsConfigFile.getMtimeMs();
940
940
  if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
941
- // models.json unchanged since last load; reloading would be redundant.
941
+ // Models config unchanged since last load; reloading would be redundant.
942
942
  return;
943
943
  }
944
944
  this.#modelsConfigFile.invalidate();
@@ -962,14 +962,14 @@ export class ModelRegistry {
962
962
  }
963
963
 
964
964
  /**
965
- * Get any error from loading models.json (undefined if no error).
965
+ * Get any error from loading custom models config (undefined if no error).
966
966
  */
967
967
  getError(): ConfigError | undefined {
968
968
  return this.#configError;
969
969
  }
970
970
 
971
971
  #loadModels() {
972
- // Load custom models from models.json first (to know which providers to override)
972
+ // Load custom config first (to know which providers to override).
973
973
  const {
974
974
  models: customModels = [],
975
975
  overrides = new Map(),
@@ -1909,7 +1909,7 @@ export class ModelRegistry {
1909
1909
 
1910
1910
  /**
1911
1911
  * Get all models (built-in + custom).
1912
- * If models.json had errors, returns only built-in models.
1912
+ * If custom config had errors, returns only built-in models.
1913
1913
  */
1914
1914
  getAll(): Model<Api>[] {
1915
1915
  return this.#models;
@@ -1209,20 +1209,27 @@ export function resolveModelOverride(
1209
1209
  modelPatterns: string[],
1210
1210
  modelRegistry: ModelLookupRegistry,
1211
1211
  settings?: Settings,
1212
- ): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean } {
1212
+ ): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean; warning?: string } {
1213
1213
  if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
1214
1214
  const availableModels = modelRegistry.getAvailable();
1215
1215
  const matchPreferences = getModelMatchPreferences(settings);
1216
+ let warning: string | undefined;
1216
1217
  for (const pattern of modelPatterns) {
1217
- const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
1218
+ const {
1219
+ model,
1220
+ thinkingLevel,
1221
+ explicitThinkingLevel,
1222
+ warning: patternWarning,
1223
+ } = resolveModelRoleValue(pattern, availableModels, {
1218
1224
  settings,
1219
1225
  matchPreferences,
1220
1226
  });
1221
1227
  if (model) {
1222
- return { model, thinkingLevel, explicitThinkingLevel };
1228
+ return { model, thinkingLevel, explicitThinkingLevel, warning: patternWarning };
1223
1229
  }
1230
+ if (!warning && patternWarning) warning = patternWarning;
1224
1231
  }
1225
- return { explicitThinkingLevel: false };
1232
+ return { explicitThinkingLevel: false, warning };
1226
1233
  }
1227
1234
 
1228
1235
  /**
@@ -1236,6 +1243,11 @@ export function resolveModelOverride(
1236
1243
  * `modelRoles.task` pointing at an unqualified id whose only available
1237
1244
  * provider variant has no configured credentials — see #985).
1238
1245
  *
1246
+ * `sessionId` is forwarded to `getApiKey` so that session-sticky OAuth
1247
+ * credentials resolve correctly during the pre-flight auth check. Without it,
1248
+ * providers with multiple OAuth accounts may return `undefined` even though
1249
+ * the credential is usable once the subagent session starts — see #5325.
1250
+ *
1239
1251
  * Keyless-by-design providers (llama.cpp, ollama, lm-studio) advertise the
1240
1252
  * `kNoAuth` sentinel from `getApiKey` to signal that they do not require
1241
1253
  * credentials. Those are treated as authenticated here so an explicitly
@@ -1251,18 +1263,20 @@ export async function resolveModelOverrideWithAuthFallback(
1251
1263
  parentActiveModelPattern: string | undefined,
1252
1264
  modelRegistry: ModelLookupRegistry & Pick<ModelRegistry, "getApiKey">,
1253
1265
  settings?: Settings,
1266
+ sessionId?: string,
1254
1267
  ): Promise<{
1255
1268
  model?: Model<Api>;
1256
1269
  thinkingLevel?: ConfiguredThinkingLevel;
1257
1270
  explicitThinkingLevel: boolean;
1258
1271
  authFallbackUsed: boolean;
1272
+ warning?: string;
1259
1273
  }> {
1260
1274
  const primary = resolveModelOverride(modelPatterns, modelRegistry, settings);
1261
1275
  if (!primary.model || !parentActiveModelPattern) {
1262
1276
  return { ...primary, authFallbackUsed: false };
1263
1277
  }
1264
1278
 
1265
- const primaryKey = await modelRegistry.getApiKey(primary.model);
1279
+ const primaryKey = await modelRegistry.getApiKey(primary.model, sessionId);
1266
1280
  if (primaryKey === kNoAuth || isAuthenticated(primaryKey)) {
1267
1281
  return { ...primary, authFallbackUsed: false };
1268
1282
  }
@@ -1274,12 +1288,12 @@ export async function resolveModelOverrideWithAuthFallback(
1274
1288
  if (modelsAreEqual(fallback.model, primary.model)) {
1275
1289
  return { ...primary, authFallbackUsed: false };
1276
1290
  }
1277
- const fallbackKey = await modelRegistry.getApiKey(fallback.model);
1291
+ const fallbackKey = await modelRegistry.getApiKey(fallback.model, sessionId);
1278
1292
  if (!isAuthenticated(fallbackKey)) {
1279
1293
  return { ...primary, authFallbackUsed: false };
1280
1294
  }
1281
1295
 
1282
- return { ...fallback, authFallbackUsed: true };
1296
+ return { ...fallback, authFallbackUsed: true, warning: primary.warning ?? fallback.warning };
1283
1297
  }
1284
1298
 
1285
1299
  /**
@@ -1,5 +1,5 @@
1
1
  /**
2
- * models.json config file handle and provider configuration validation.
2
+ * Custom model/provider config file handle and validation.
3
3
  */
4
4
 
5
5
  import type { Api, ModelSpec } from "@oh-my-pi/pi-ai/types";
@@ -3592,6 +3592,16 @@ export const SETTINGS_SCHEMA = {
3592
3592
  description: "Enable the tts tool for on-device (Kokoro) or xAI Grok Voice speech-file synthesis",
3593
3593
  },
3594
3594
  },
3595
+ "generate_image.enabled": {
3596
+ type: "boolean",
3597
+ default: true,
3598
+ ui: {
3599
+ tab: "tools",
3600
+ group: "Available Tools",
3601
+ label: "Generate Image",
3602
+ description: "Enable the generate_image tool for text-to-image generation and editing",
3603
+ },
3604
+ },
3595
3605
 
3596
3606
  "inspect_image.enabled": {
3597
3607
  type: "boolean",
package/src/cursor.ts CHANGED
@@ -93,6 +93,7 @@ async function executeTool(
93
93
  result = buildToolErrorResult(message);
94
94
  isError = true;
95
95
  }
96
+ isError ||= result.isError === true;
96
97
 
97
98
  const sanitizedFinalResult: AgentToolResult<unknown> = {
98
99
  content: result.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
@@ -283,6 +284,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
283
284
  result = buildToolErrorResult(message);
284
285
  isError = true;
285
286
  }
287
+ isError ||= result.isError === true;
286
288
 
287
289
  // onUpdate may not fire for every chunk — flush any remaining output
288
290
  // from the final result that wasn't already streamed.
@@ -24,7 +24,7 @@ import {
24
24
  scanSkillsFromDir,
25
25
  } from "./helpers";
26
26
 
27
- import { substitutePluginRoot } from "./substitute-plugin-root";
27
+ import { resolvePluginStdioPaths, substitutePluginRoot } from "./substitute-plugin-root";
28
28
 
29
29
  const PROVIDER_ID = "claude-plugins";
30
30
  const DISPLAY_NAME = "Claude Code Marketplace";
@@ -441,14 +441,20 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
441
441
  continue;
442
442
  }
443
443
  const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
444
+ const substitutedCommand =
445
+ raw.command !== undefined ? substitutePluginRoot(raw.command, root.path) : undefined;
446
+ const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined;
447
+ // Root relative command/cwd at the plugin's config directory, not the
448
+ // session cwd (MCP stdio spawning resolves relative values there).
449
+ const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, root.path);
444
450
  const server: MCPServer = {
445
451
  name: namespacedName,
446
452
  ...(raw.enabled !== undefined && { enabled: raw.enabled }),
447
453
  ...(raw.timeout !== undefined && { timeout: raw.timeout }),
448
- ...(raw.command !== undefined && { command: substitutePluginRoot(raw.command, root.path) }),
454
+ ...(rooted.command !== undefined && { command: rooted.command }),
449
455
  ...(raw.args !== undefined && { args: substitutePluginRoot(raw.args, root.path) }),
450
456
  ...(raw.env !== undefined && { env: substitutePluginRoot(raw.env, root.path) }),
451
- ...(raw.cwd !== undefined && { cwd: substitutePluginRoot(raw.cwd, root.path) }),
457
+ ...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
452
458
  ...(raw.url !== undefined && { url: expandEnvVarsDeep(raw.url) }),
453
459
  ...(raw.headers !== undefined && { headers: expandEnvVarsDeep(raw.headers) }),
454
460
  ...(raw.auth !== undefined && { auth: raw.auth }),
@@ -30,6 +30,7 @@ import { type CustomTool, toolCapability } from "../capability/tool";
30
30
  import type { LoadContext, LoadResult } from "../capability/types";
31
31
  import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
32
32
  import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots";
33
+ import { resolvePluginStdioPaths } from "./substitute-plugin-root";
33
34
 
34
35
  const PROVIDER_ID = "omp-plugins";
35
36
  const DISPLAY_NAME = "OMP Extension Packages";
@@ -301,14 +302,17 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
301
302
  warnings.push(`[omp-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
302
303
  continue;
303
304
  }
305
+ // Root relative command/cwd at the plugin's config directory, not the
306
+ // session cwd (MCP stdio spawning resolves relative values there).
307
+ const rooted = resolvePluginStdioPaths({ command: cfg.command, cwd: cfg.cwd }, root.path);
304
308
  items.push({
305
309
  name: serverName,
306
310
  ...(cfg.enabled !== undefined && { enabled: cfg.enabled }),
307
311
  ...(cfg.timeout !== undefined && { timeout: cfg.timeout }),
308
- ...(cfg.command !== undefined && { command: cfg.command }),
312
+ ...(rooted.command !== undefined && { command: rooted.command }),
309
313
  ...(cfg.args !== undefined && { args: cfg.args }),
310
314
  ...(cfg.env !== undefined && { env: cfg.env }),
311
- ...(cfg.cwd !== undefined && { cwd: cfg.cwd }),
315
+ ...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
312
316
  ...(cfg.url !== undefined && { url: cfg.url }),
313
317
  ...(cfg.headers !== undefined && { headers: cfg.headers }),
314
318
  ...(cfg.auth !== undefined && { auth: cfg.auth }),
@@ -1,3 +1,5 @@
1
+ import * as path from "node:path";
2
+
1
3
  /**
2
4
  * Recursively substitute ${CLAUDE_PLUGIN_ROOT} and ${OMP_PLUGIN_ROOT}
3
5
  * with the actual plugin root path in strings, arrays, and plain objects.
@@ -27,3 +29,33 @@ export function substitutePluginRoot<T>(value: T, rootPath: string): T {
27
29
  }
28
30
  return value;
29
31
  }
32
+
33
+ /**
34
+ * Rebase relative filesystem values in a discovered plugin stdio config against
35
+ * the directory of the `.mcp.json` that declared them.
36
+ *
37
+ * External plugin configs (bundled ChatGPT/Codex plugins, Claude marketplace
38
+ * plugins) express `command`/`cwd` relative to their own config file, but MCP
39
+ * stdio spawning roots relative values at the session cwd — so a plugin shipping
40
+ * `command: "./bin/server"`, `cwd: "."` launches from the wrong directory and
41
+ * fails with ENOENT. This resolves those against `configDir` instead:
42
+ *
43
+ * - relative `cwd` → resolved against `configDir`;
44
+ * - path-like `command` (`./`, `../`, or the Windows `.\`/`..\` forms) →
45
+ * resolved against `configDir`;
46
+ * - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched.
47
+ */
48
+ export function resolvePluginStdioPaths(
49
+ config: { command?: string; cwd?: string },
50
+ configDir: string,
51
+ ): { command?: string; cwd?: string } {
52
+ const resolved: { command?: string; cwd?: string } = {};
53
+ if (typeof config.cwd === "string") {
54
+ resolved.cwd = path.isAbsolute(config.cwd) ? config.cwd : path.resolve(configDir, config.cwd);
55
+ }
56
+ if (config.command !== undefined) {
57
+ const isPathLike = /^\.\.?[/\\]/.test(config.command);
58
+ resolved.command = isPathLike ? path.resolve(configDir, config.command) : config.command;
59
+ }
60
+ return resolved;
61
+ }
@@ -603,21 +603,22 @@ describe("agent() through eval runtimes", () => {
603
603
  });
604
604
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
605
605
  mockAgents();
606
- // Subagents that ignore the abort for far longer than the kernel's SIGINT
607
- // escalation window. Each kernel worker thread blocks in a synchronous
608
- // `urllib` bridge call, joined by `parallel()`'s ThreadPoolExecutor exit.
609
- // The host must respond the instant the cell aborts so the kernel can
610
- // unwind via KeyboardInterrupt instead of being hard-killed (which used to
611
- // surface "[kernel] Python kernel shutdown" and lose all session state).
606
+ // Each kernel worker thread blocks in a synchronous `urllib` bridge call,
607
+ // joined by `parallel()`'s ThreadPoolExecutor exit. The host must keep
608
+ // those already-started calls attached until they settle, then interrupt
609
+ // the kernel before `parallel()` launches another wave.
612
610
  let inFlight = 0;
611
+ let completed = 0;
613
612
  let markSaturated: (() => void) | undefined;
614
613
  const saturated = new Promise<void>(resolve => {
615
614
  markSaturated = resolve;
616
615
  });
617
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
616
+ const releaseAgents = Promise.withResolvers<void>();
617
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
618
618
  // task.maxConcurrency=6 → six bridge calls block at once; signal then.
619
619
  if (++inFlight >= 6) markSaturated?.();
620
- await Bun.sleep(9000); // deliberately ignores options.signal
620
+ await releaseAgents.promise;
621
+ completed++;
621
622
  return singleResult(options, { output: options.assignment ?? "" });
622
623
  });
623
624
 
@@ -640,8 +641,7 @@ describe("agent() through eval runtimes", () => {
640
641
  // bridge calls (condition-driven) instead of waiting a fixed wall second.
641
642
  void saturated.then(() => ac.abort(new Error("external interrupt")));
642
643
 
643
- const start = Date.now();
644
- const result = await executePython(
644
+ const resultPromise = executePython(
645
645
  "import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
646
646
  {
647
647
  cwd: tempDir.path(),
@@ -653,13 +653,18 @@ describe("agent() through eval runtimes", () => {
653
653
  signal: ac.signal,
654
654
  },
655
655
  );
656
- const elapsed = Date.now() - start;
656
+ await saturated;
657
+ await Promise.resolve();
658
+ expect(completed).toBe(0);
659
+ releaseAgents.resolve();
660
+ const result = await resultPromise;
657
661
 
658
- // Cancelled, but cleanly: no hard-kill, settled well within the kernel's 5s
659
- // SIGINT escalation window rather than ~6s after it.
662
+ // Cancelled, but cleanly: no hard-kill, no orphaned bridge calls, and no
663
+ // second fan-out wave started after the deferred abort was delivered.
660
664
  expect(result.cancelled).toBe(true);
661
665
  expect(result.output).not.toContain("Python kernel shutdown");
662
- expect(elapsed).toBeLessThan(4000);
666
+ expect(completed).toBe(6);
667
+ expect(runSpy).toHaveBeenCalledTimes(6);
663
668
 
664
669
  // The persistent kernel survived the interrupt: prior state is intact.
665
670
  const after = await executePython("print(PREP_MARKER)", {
@@ -5,7 +5,9 @@ import {
5
5
  isEvalTimeoutControlEvent,
6
6
  withBridgeTimeoutPause,
7
7
  } from "../bridge-timeout";
8
+ import { executeWithKernelBase, type GenericKernel } from "../executor-base";
8
9
  import type { JsStatusEvent } from "../js/shared/types";
10
+ import type { KernelDisplayOutput } from "../py/display";
9
11
 
10
12
  describe("withBridgeTimeoutPause", () => {
11
13
  it("emits one pause before the operation and one resume after it settles", async () => {
@@ -17,10 +19,12 @@ describe("withBridgeTimeoutPause", () => {
17
19
  await Bun.sleep(80);
18
20
  return "done";
19
21
  },
22
+ { deferExternalAbort: true },
20
23
  );
21
24
 
22
25
  expect(value).toBe("done");
23
26
  expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
27
+ expect(events.every(event => event.deferExternalAbort === true)).toBe(true);
24
28
 
25
29
  const settledCount = events.length;
26
30
  await Bun.sleep(40);
@@ -62,3 +66,105 @@ describe("withBridgeTimeoutPause", () => {
62
66
  expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
63
67
  });
64
68
  });
69
+
70
+ class TestCancelledError extends Error {
71
+ readonly timedOut: boolean;
72
+
73
+ constructor(timedOut: boolean) {
74
+ super(timedOut ? "timed out" : "cancelled");
75
+ this.name = "TestCancelledError";
76
+ this.timedOut = timedOut;
77
+ }
78
+ }
79
+
80
+ it("defers external aborts until an in-flight agent bridge call resumes", async () => {
81
+ const abortController = new AbortController();
82
+ const entered = Promise.withResolvers<void>();
83
+ const triggerAbort = Promise.withResolvers<void>();
84
+ const observed = Promise.withResolvers<boolean>();
85
+ const release = Promise.withResolvers<void>();
86
+ const kernel: GenericKernel<Record<string, string | null>> = {
87
+ async execute(_code, options) {
88
+ entered.resolve();
89
+ await triggerAbort.promise;
90
+ options.onDisplay({
91
+ type: "status",
92
+ event: { op: EVAL_TIMEOUT_PAUSE_OP, deferExternalAbort: true },
93
+ } satisfies KernelDisplayOutput);
94
+ abortController.abort(new Error("external interrupt"));
95
+ observed.resolve(options.signal?.aborted ?? false);
96
+ await release.promise;
97
+ options.onDisplay({
98
+ type: "status",
99
+ event: { op: EVAL_TIMEOUT_RESUME_OP, deferExternalAbort: true },
100
+ } satisfies KernelDisplayOutput);
101
+ return { status: "ok", cancelled: false, timedOut: false };
102
+ },
103
+ };
104
+
105
+ const resultPromise = executeWithKernelBase({
106
+ kernel,
107
+ code: "agent('slow')",
108
+ options: { signal: abortController.signal },
109
+ runIdPrefix: "test",
110
+ errorLogLabel: "test",
111
+ cancelledErrorClass: TestCancelledError,
112
+ buildKernelEnvPatch: () => ({}),
113
+ formatKernelTimeoutAnnotation: () => "kernel timed out",
114
+ formatTimeoutAnnotation: () => "timed out",
115
+ });
116
+
117
+ await entered.promise;
118
+ triggerAbort.resolve();
119
+ expect(await observed.promise).toBe(false);
120
+ release.resolve();
121
+ const result = await resultPromise;
122
+ expect(result.cancelled).toBe(true);
123
+ expect(result.exitCode).toBeUndefined();
124
+ });
125
+
126
+ it("does not defer external aborts for a completion bridge call", async () => {
127
+ const abortController = new AbortController();
128
+ const entered = Promise.withResolvers<void>();
129
+ const triggerAbort = Promise.withResolvers<void>();
130
+ const observed = Promise.withResolvers<boolean>();
131
+ const release = Promise.withResolvers<void>();
132
+ const kernel: GenericKernel<Record<string, string | null>> = {
133
+ async execute(_code, options) {
134
+ entered.resolve();
135
+ await triggerAbort.promise;
136
+ options.onDisplay({
137
+ type: "status",
138
+ event: { op: EVAL_TIMEOUT_PAUSE_OP },
139
+ } satisfies KernelDisplayOutput);
140
+ abortController.abort(new Error("external interrupt"));
141
+ observed.resolve(options.signal?.aborted ?? false);
142
+ await release.promise;
143
+ options.onDisplay({
144
+ type: "status",
145
+ event: { op: EVAL_TIMEOUT_RESUME_OP },
146
+ } satisfies KernelDisplayOutput);
147
+ return { status: "ok", cancelled: false, timedOut: false };
148
+ },
149
+ };
150
+
151
+ const resultPromise = executeWithKernelBase({
152
+ kernel,
153
+ code: "completion('slow')",
154
+ options: { signal: abortController.signal },
155
+ runIdPrefix: "test",
156
+ errorLogLabel: "test",
157
+ cancelledErrorClass: TestCancelledError,
158
+ buildKernelEnvPatch: () => ({}),
159
+ formatKernelTimeoutAnnotation: () => "kernel timed out",
160
+ formatTimeoutAnnotation: () => "timed out",
161
+ });
162
+
163
+ await entered.promise;
164
+ triggerAbort.resolve();
165
+ expect(await observed.promise).toBe(true);
166
+ release.resolve();
167
+ const result = await resultPromise;
168
+ expect(result.cancelled).toBe(true);
169
+ expect(result.exitCode).toBeUndefined();
170
+ });