@oh-my-pi/pi-coding-agent 17.0.0 → 17.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/cli.js +3305 -3302
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/cli-commands.d.ts +14 -1
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/models-config-schema.d.ts +10 -0
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +1 -1
  9. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  10. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  11. package/dist/types/lib/xai-http.d.ts +11 -0
  12. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  13. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  14. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  15. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  17. package/dist/types/modes/components/hook-editor.d.ts +9 -2
  18. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  19. package/dist/types/modes/components/session-selector.d.ts +2 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  21. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  22. package/dist/types/modes/interactive-mode.d.ts +6 -0
  23. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  24. package/dist/types/modes/types.d.ts +10 -0
  25. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  26. package/dist/types/session/agent-session.d.ts +9 -1
  27. package/dist/types/session/streaming-output.d.ts +2 -0
  28. package/dist/types/slash-commands/types.d.ts +4 -4
  29. package/dist/types/system-prompt.d.ts +1 -1
  30. package/dist/types/tools/browser/registry.d.ts +7 -3
  31. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +3 -1
  33. package/dist/types/tools/manage-skill.d.ts +2 -0
  34. package/dist/types/web/search/index.d.ts +2 -0
  35. package/dist/types/web/search/providers/base.d.ts +3 -0
  36. package/package.json +12 -12
  37. package/src/advisor/__tests__/advisor.test.ts +22 -12
  38. package/src/advisor/advise-tool.ts +7 -3
  39. package/src/cli/bench-cli.ts +4 -1
  40. package/src/cli/grep-cli.ts +2 -1
  41. package/src/cli-commands.ts +57 -23
  42. package/src/config/model-registry.ts +9 -0
  43. package/src/config/model-resolver.ts +43 -5
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +2 -1
  46. package/src/discovery/codex.ts +14 -4
  47. package/src/discovery/substitute-plugin-root.ts +23 -7
  48. package/src/export/html/template.js +2 -0
  49. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  50. package/src/internal-urls/agent-protocol.ts +73 -39
  51. package/src/lib/xai-http.ts +28 -2
  52. package/src/modes/acp/acp-agent.ts +28 -9
  53. package/src/modes/acp/acp-event-mapper.ts +8 -0
  54. package/src/modes/acp/acp-mode.ts +18 -4
  55. package/src/modes/components/assistant-message.ts +35 -14
  56. package/src/modes/components/chat-transcript-builder.ts +2 -0
  57. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  58. package/src/modes/components/custom-editor.ts +62 -1
  59. package/src/modes/components/hook-editor.ts +17 -2
  60. package/src/modes/components/login-dialog.test.ts +56 -0
  61. package/src/modes/components/login-dialog.ts +7 -3
  62. package/src/modes/components/plan-review-overlay.ts +51 -10
  63. package/src/modes/components/session-selector.ts +6 -0
  64. package/src/modes/components/status-line/component.ts +2 -4
  65. package/src/modes/components/tool-execution.test.ts +101 -0
  66. package/src/modes/components/tool-execution.ts +117 -9
  67. package/src/modes/components/transcript-container.ts +30 -2
  68. package/src/modes/controllers/command-controller-shared.ts +1 -1
  69. package/src/modes/controllers/command-controller.ts +1 -1
  70. package/src/modes/controllers/event-controller.ts +1 -0
  71. package/src/modes/controllers/selector-controller.ts +19 -17
  72. package/src/modes/interactive-mode.ts +66 -11
  73. package/src/modes/noninteractive-dispose.test.ts +60 -0
  74. package/src/modes/print-mode.ts +8 -3
  75. package/src/modes/rpc/rpc-mode.ts +12 -3
  76. package/src/modes/theme/theme.ts +2 -2
  77. package/src/modes/types.ts +10 -0
  78. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  79. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  80. package/src/prompts/system/system-prompt.md +1 -1
  81. package/src/sdk.ts +6 -2
  82. package/src/session/agent-session.ts +79 -34
  83. package/src/session/session-listing.ts +6 -5
  84. package/src/session/session-loader.ts +9 -0
  85. package/src/session/session-persistence.ts +11 -0
  86. package/src/session/streaming-output.ts +3 -1
  87. package/src/slash-commands/builtin-registry.ts +2 -0
  88. package/src/slash-commands/types.ts +4 -4
  89. package/src/system-prompt.ts +2 -2
  90. package/src/tools/browser/registry.ts +9 -2
  91. package/src/tools/browser/tab-supervisor.ts +65 -10
  92. package/src/tools/browser.ts +4 -3
  93. package/src/tools/eval.ts +3 -0
  94. package/src/tools/image-gen.ts +561 -512
  95. package/src/tools/index.ts +3 -1
  96. package/src/tools/manage-skill.ts +5 -3
  97. package/src/tools/output-meta.ts +7 -0
  98. package/src/tools/path-utils.ts +8 -5
  99. package/src/web/search/index.ts +21 -6
  100. package/src/web/search/providers/base.ts +3 -0
  101. package/src/web/search/providers/xai.ts +40 -9
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "17.0.0",
4
+ "version": "17.0.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "17.0.0",
56
- "@oh-my-pi/omp-stats": "17.0.0",
57
- "@oh-my-pi/pi-agent-core": "17.0.0",
58
- "@oh-my-pi/pi-ai": "17.0.0",
59
- "@oh-my-pi/pi-catalog": "17.0.0",
60
- "@oh-my-pi/pi-mnemopi": "17.0.0",
61
- "@oh-my-pi/pi-natives": "17.0.0",
62
- "@oh-my-pi/pi-tui": "17.0.0",
63
- "@oh-my-pi/pi-utils": "17.0.0",
64
- "@oh-my-pi/pi-wire": "17.0.0",
65
- "@oh-my-pi/snapcompact": "17.0.0",
55
+ "@oh-my-pi/hashline": "17.0.1",
56
+ "@oh-my-pi/omp-stats": "17.0.1",
57
+ "@oh-my-pi/pi-agent-core": "17.0.1",
58
+ "@oh-my-pi/pi-ai": "17.0.1",
59
+ "@oh-my-pi/pi-catalog": "17.0.1",
60
+ "@oh-my-pi/pi-mnemopi": "17.0.1",
61
+ "@oh-my-pi/pi-natives": "17.0.1",
62
+ "@oh-my-pi/pi-tui": "17.0.1",
63
+ "@oh-my-pi/pi-utils": "17.0.1",
64
+ "@oh-my-pi/pi-wire": "17.0.1",
65
+ "@oh-my-pi/snapcompact": "17.0.1",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/context-async-hooks": "^2.7.1",
68
68
  "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0",
@@ -2568,18 +2568,28 @@ describe("advisor", () => {
2568
2568
  }
2569
2569
  });
2570
2570
 
2571
- it("preserves a late interrupting note when the primary already ended with a terminal answer", () => {
2572
- for (const severity of ["concern", "blocker"] as const) {
2573
- expect(
2574
- resolveAdvisorDeliveryChannel({
2575
- severity,
2576
- autoResumeSuppressed: false,
2577
- streaming: false,
2578
- aborting: false,
2579
- terminalAnswerNoQueuedWork: true,
2580
- }),
2581
- ).toBe("preserve");
2582
- }
2571
+ it("preserves a late concern when the primary already ended with a terminal answer", () => {
2572
+ expect(
2573
+ resolveAdvisorDeliveryChannel({
2574
+ severity: "concern",
2575
+ autoResumeSuppressed: false,
2576
+ streaming: false,
2577
+ aborting: false,
2578
+ terminalAnswerNoQueuedWork: true,
2579
+ }),
2580
+ ).toBe("preserve");
2581
+ });
2582
+
2583
+ it("steers a late blocker after a terminal answer so the primary continues and acknowledges it (#5628)", () => {
2584
+ expect(
2585
+ resolveAdvisorDeliveryChannel({
2586
+ severity: "blocker",
2587
+ autoResumeSuppressed: false,
2588
+ streaming: false,
2589
+ aborting: false,
2590
+ terminalAnswerNoQueuedWork: true,
2591
+ }),
2592
+ ).toBe("steer");
2583
2593
  });
2584
2594
 
2585
2595
  it("routes interrupting notes to the aside queue during immune turns without overriding preservation", () => {
@@ -106,8 +106,11 @@ export function isAdvisorInterruptImmuneTurnActive(opts: {
106
106
  * the live turn while one is streaming, or (when idle) a triggered turn so the
107
107
  * advice is acted on immediately.
108
108
  * - If the primary tail is already a terminal text answer and there is no queued
109
- * work, late interrupting advice is preserved as a visible card instead of
110
- * waking the primary to restate completion.
109
+ * work, a late `concern` is preserved as a visible card instead of waking the
110
+ * primary to restate completion. A `blocker` is the exception: it means the
111
+ * agent handed off broken or unexercised work, so it still steers a triggered
112
+ * turn to force the primary to acknowledge and continue before the turn is
113
+ * considered done (#5628) — deferring it to the next user turn is the bug.
111
114
  * - After a deliberate user interrupt (`autoResumeSuppressed`) the advisor must
112
115
  * not auto-resume the stopped run. While the agent is idle — or still tearing
113
116
  * the interrupted turn down (`aborting`) — the note is preserved as a visible
@@ -129,7 +132,8 @@ export function resolveAdvisorDeliveryChannel(opts: {
129
132
  }): AdvisorDeliveryChannel {
130
133
  if (!isInterruptingSeverity(opts.severity)) return "aside";
131
134
  if (opts.autoResumeSuppressed && (opts.aborting || !opts.streaming)) return "preserve";
132
- if (opts.terminalAnswerNoQueuedWork && !opts.streaming && !opts.aborting) return "preserve";
135
+ if (opts.terminalAnswerNoQueuedWork && opts.severity !== "blocker" && !opts.streaming && !opts.aborting)
136
+ return "preserve";
133
137
  if (opts.interruptImmuneTurnActive) return "aside";
134
138
  return "steer";
135
139
  }
@@ -159,12 +159,14 @@ function isFirstTokenEvent(event: AssistantMessageEvent): boolean {
159
159
  case "text_end":
160
160
  case "thinking_end":
161
161
  return event.content.length > 0;
162
+ case "image_end":
163
+ return true;
162
164
  default:
163
165
  return false;
164
166
  }
165
167
  }
166
168
 
167
- /** Final message carries visible output — non-empty text/thinking or a tool call. */
169
+ /** Final message carries visible output — non-empty text/thinking, an image, or a tool call. */
168
170
  function hasVisibleFinalContent(message: AssistantMessage): boolean {
169
171
  return message.content.some(block => {
170
172
  switch (block.type) {
@@ -172,6 +174,7 @@ function hasVisibleFinalContent(message: AssistantMessage): boolean {
172
174
  return block.text.length > 0;
173
175
  case "thinking":
174
176
  return block.thinking.length > 0;
177
+ case "image":
175
178
  case "redactedThinking":
176
179
  case "toolCall":
177
180
  return true;
@@ -7,6 +7,7 @@ import * as path from "node:path";
7
7
  import { GrepOutputMode, grep } from "@oh-my-pi/pi-natives";
8
8
  import { APP_NAME } from "@oh-my-pi/pi-utils";
9
9
  import chalk from "chalk";
10
+ import { expandPath } from "../tools/path-utils";
10
11
 
11
12
  export interface GrepCommandArgs {
12
13
  pattern: string;
@@ -73,7 +74,7 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise<void> {
73
74
  process.exit(1);
74
75
  }
75
76
 
76
- const searchPath = path.resolve(cmd.path);
77
+ const searchPath = path.resolve(expandPath(cmd.path));
77
78
  console.log(chalk.dim(`Searching in: ${searchPath}`));
78
79
  console.log(chalk.dim(`Pattern: ${cmd.pattern}`));
79
80
  console.log(
@@ -46,31 +46,65 @@ export const commands: CommandEntry[] = [
46
46
  { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
47
47
  ];
48
48
 
49
- // Documented-looking plugin-management verbs that are NOT registered top-level
50
- // commands. Without a guard `resolveCliArgv` rewrites e.g. `omp list` to
51
- // `omp launch list`, silently forwarding the bare verb to the model as a prompt
52
- // instead of managing plugins (#2935; same class as the `install` leak fixed in
53
- // #1496/#1498). A bare (single-arg) use gets a hint pointing at the real
54
- // `omp plugin <action>` command; multi-word invocations still fall through to
55
- // `launch`, so genuine prompts that merely begin with one of these words work.
56
- const RESERVED_TOP_LEVEL_WORDS = new Map<string, string>([
57
- [
58
- "extensions",
49
+ // Documented-looking plugin/marketplace verbs that are NOT registered top-level
50
+ // commands. Without a guard `resolveCliArgv` rewrites e.g. `omp marketplace add
51
+ // xyz` to `omp launch marketplace add xyz`, silently forwarding the argv to the
52
+ // model as a prompt instead of managing plugins (#4845; same class as the
53
+ // `list`/`remove` leak fixed in #2935 and the `install` leak in #1496/#1498).
54
+ // The real commands live under `omp plugin <action>`; each entry maps a verb to
55
+ // a hint pointing there. See {@link reservedTopLevelWordMessage} for when a hint
56
+ // fires vs. when the argv still falls through to `launch`.
57
+ const RESERVED_TOP_LEVEL_WORDS: Record<string, string> = {
58
+ extensions:
59
59
  '`omp extensions` is not a management command. Use `omp plugin list` / `omp plugin install`, or run `omp launch extensions` if you meant to send "extensions" as a prompt.',
60
- ],
61
- [
62
- "list",
63
- '`omp list` is not a top-level command. Use `omp plugin list` to list installed plugins, or run `omp launch list` if you meant to send "list" as a prompt.',
64
- ],
65
- [
66
- "remove",
60
+ list: '`omp list` is not a top-level command. Use `omp plugin list` to list installed plugins, or run `omp launch list` if you meant to send "list" as a prompt.',
61
+ remove:
67
62
  '`omp remove` is not a top-level command. Use `omp plugin uninstall <name>` to remove a plugin, or run `omp launch remove` if you meant to send "remove" as a prompt.',
68
- ],
69
- ]);
63
+ uninstall:
64
+ '`omp uninstall` is not a top-level command. Use `omp plugin uninstall <name@marketplace>` to remove a plugin, or run `omp launch uninstall` if you meant to send "uninstall" as a prompt.',
65
+ marketplace:
66
+ '`omp marketplace` is not a top-level command. Use `omp plugin marketplace <add|remove|update|list>` to manage marketplaces, or run `omp launch marketplace` if you meant to send "marketplace" as a prompt.',
67
+ discover:
68
+ '`omp discover` is not a top-level command. Use `omp plugin discover [marketplace]` to browse available plugins, or run `omp launch discover` if you meant to send "discover" as a prompt.',
69
+ upgrade:
70
+ '`omp upgrade` is not a top-level command. Use `omp plugin upgrade [name@marketplace]` to upgrade plugins, or run `omp launch upgrade` if you meant to send "upgrade" as a prompt.',
71
+ enable:
72
+ '`omp enable` is not a top-level command. Use `omp plugin enable <name@marketplace>` to enable a plugin, or run `omp launch enable` if you meant to send "enable" as a prompt.',
73
+ disable:
74
+ '`omp disable` is not a top-level command. Use `omp plugin disable <name@marketplace>` to disable a plugin, or run `omp launch disable` if you meant to send "disable" as a prompt.',
75
+ };
70
76
 
71
- export function reservedTopLevelWordMessage(first: string | undefined, argc = 1): string | undefined {
72
- if (argc !== 1 || !first || first.startsWith("-") || first.startsWith("@")) return undefined;
73
- return RESERVED_TOP_LEVEL_WORDS.get(first);
77
+ // Sub-actions that make `omp marketplace <sub>` unambiguously a management
78
+ // command even when multi-word (the reporter's `omp marketplace add xyz`,
79
+ // #4845). Mirrors the switch in `handleMarketplace` (cli/plugin-cli.ts).
80
+ const MARKETPLACE_SUBCOMMANDS: Record<string, true> = { add: true, remove: true, rm: true, update: true, list: true };
81
+
82
+ /**
83
+ * Hint for a reserved plugin/marketplace verb used as a top-level command, or
84
+ * `undefined` when the argv should fall through to `launch`.
85
+ *
86
+ * A bare verb (`omp marketplace`) always hints. A multi-word invocation only
87
+ * hints when the arguments follow the documented plugin grammar — a marketplace
88
+ * sub-action (`omp marketplace add …`) or a `name@marketplace` plugin id
89
+ * (`omp uninstall foo@bar`) — so genuine prompts that merely begin with one of
90
+ * these words (`omp list all my files`, `omp upgrade the deps`) still launch.
91
+ *
92
+ * Flags (`-…`) and `@file` arguments in the verb slot are never management
93
+ * commands; those fall through to the default `launch` command.
94
+ */
95
+ export function reservedTopLevelWordMessage(argv: readonly string[]): string | undefined {
96
+ const first = argv[0];
97
+ if (!first || first.startsWith("-") || first.startsWith("@")) return undefined;
98
+ const hint = RESERVED_TOP_LEVEL_WORDS[first];
99
+ if (!hint) return undefined;
100
+ const second = argv[1];
101
+ if (second === undefined) return hint;
102
+ if (first === "marketplace" && MARKETPLACE_SUBCOMMANDS[second]) return hint;
103
+ for (let index = 1; index < argv.length; index += 1) {
104
+ const arg = argv[index];
105
+ if (!arg.startsWith("-") && arg.includes("@")) return hint;
106
+ }
107
+ return undefined;
74
108
  }
75
109
 
76
110
  /**
@@ -112,7 +146,7 @@ function leadingSubcommandIndex(argv: string[]): number {
112
146
  */
113
147
  export function resolveCliArgv(argv: string[]): ResolvedCliArgv {
114
148
  const first = argv[0];
115
- const reservedMessage = reservedTopLevelWordMessage(first, argv.length);
149
+ const reservedMessage = reservedTopLevelWordMessage(argv);
116
150
  if (reservedMessage) return { error: reservedMessage };
117
151
  if (first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help") {
118
152
  return { argv };
@@ -1993,6 +1993,15 @@ export class ModelRegistry {
1993
1993
  getProviderBaseUrl(provider: string): string | undefined {
1994
1994
  return this.#models.find(m => m.provider === provider && m.baseUrl)?.baseUrl;
1995
1995
  }
1996
+ /**
1997
+ * Get provider-level headers without including per-model overrides.
1998
+ */
1999
+ getProviderHeaders(provider: string): Record<string, string> | undefined {
2000
+ return createLiveConfigHeaders([
2001
+ this.#providerOverrides.get(provider)?.headers,
2002
+ this.#runtimeProviderOverrides.get(provider)?.headers,
2003
+ ]);
2004
+ }
1996
2005
 
1997
2006
  /**
1998
2007
  * Get API key for a model.
@@ -623,11 +623,18 @@ function findExactModelReferenceMatch(modelReference: string, availableModels: M
623
623
  * 4. provider-scoped fuzzy match,
624
624
  * 5. substring match with the alias-vs-dated pick.
625
625
  * Returns the matched model or undefined if no match found.
626
+ *
627
+ * `exactOnly` stops after the exact phases (1-3), skipping the fuzzy/substring
628
+ * fallbacks (4-5). Callers use it to resolve the full selector exactly before
629
+ * a trailing `:<level>` thinking suffix is split off, so the suffix can never
630
+ * be fuzzily absorbed into a longer sibling id (e.g. `kimi-for-coding:high`
631
+ * must not match `kimi-for-coding-highspeed`).
626
632
  */
627
633
  function matchModel(
628
634
  modelPattern: string,
629
635
  availableModels: Model<Api>[],
630
636
  context: ModelPreferenceContext,
637
+ options?: { exactOnly?: boolean },
631
638
  ): Model<Api> | undefined {
632
639
  const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
633
640
  if (exactRefMatch) {
@@ -663,6 +670,14 @@ function matchModel(
663
670
  return pickPreferredModel(preferred.length > 0 ? preferred : aliasMatches, context);
664
671
  }
665
672
  }
673
+
674
+ // Exact phases exhausted. Fuzzy/substring fallbacks (below) subsequence-match
675
+ // the whole pattern and would let a trailing `:<level>` thinking suffix bleed
676
+ // into a longer sibling id; callers that still hold an unstripped suffix ask
677
+ // for exact-only so the suffix is split off before any fuzzy attempt.
678
+ if (options?.exactOnly) {
679
+ return undefined;
680
+ }
666
681
  // Check for provider/modelId format — fuzzy match within provider only.
667
682
  const slashIndex = modelPattern.indexOf("/");
668
683
  if (slashIndex !== -1) {
@@ -766,17 +781,33 @@ function parseModelPatternWithContext(
766
781
  context: ModelPreferenceContext,
767
782
  options?: { allowInvalidThinkingSelectorFallback?: boolean },
768
783
  ): ParsedModelResult {
769
- // Try exact match first
770
- const exactMatch = matchModel(pattern, availableModels, context);
784
+ // Exact match on the full pattern first (no fuzzy): a literal id that
785
+ // contains a colon (`coding-router:max`) wins over any suffix split.
786
+ const exactMatch = matchModel(pattern, availableModels, context, { exactOnly: true });
771
787
  if (exactMatch) {
772
788
  return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
773
789
  }
774
790
 
775
- // No match - try stripping a valid thinking suffix and recursing.
776
- // `max` is accepted only after the full pattern failed, so literal model IDs
777
- // ending in `:max` keep winning over the thinking suffix.
791
+ // Prefer a fuzzy match whose actual id ends in the suffix, preserving
792
+ // shorthand selectors for literal tier models such as `router:low`. Other
793
+ // fuzzy results (e.g. `kimi-for-coding-highspeed`) cannot absorb the suffix.
778
794
  const { base, level } = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS);
779
795
  if (level) {
796
+ const literalSuffixMatch = matchModel(pattern, availableModels, context);
797
+ if (literalSuffixMatch?.id.toLowerCase().endsWith(`:${level}`)) {
798
+ return {
799
+ model: literalSuffixMatch,
800
+ thinkingLevel: undefined,
801
+ warning: undefined,
802
+ explicitThinkingLevel: false,
803
+ };
804
+ }
805
+
806
+ // Strip a valid thinking suffix and recurse before accepting any other
807
+ // fuzzy match, so `:<level>` cannot be absorbed into a longer sibling
808
+ // id (e.g. `kimi-for-coding:high` must not match
809
+ // `kimi-for-coding-highspeed`). `max` is accepted only after the exact
810
+ // match above failed, so literal model IDs ending in `:max` keep winning.
780
811
  const result = parseModelPatternWithContext(base, availableModels, context, options);
781
812
  if (result.model) {
782
813
  // Only use this thinking level if no warning from inner recursion
@@ -791,6 +822,13 @@ function parseModelPatternWithContext(
791
822
  return result;
792
823
  }
793
824
 
825
+ // No valid thinking suffix: fall back to fuzzy/substring matching on the
826
+ // whole pattern.
827
+ const fallbackMatch = matchModel(pattern, availableModels, context);
828
+ if (fallbackMatch) {
829
+ return { model: fallbackMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
830
+ }
831
+
794
832
  const lastColonIndex = pattern.lastIndexOf(":");
795
833
  if (lastColonIndex === -1) {
796
834
  // No colons, pattern simply doesn't match any model
@@ -60,6 +60,7 @@ const OpenAICompatFields = {
60
60
  "strictResponsesPairing?": "boolean",
61
61
  "supportsImageDetailOriginal?": "boolean",
62
62
  // anthropic-messages compat flags (same `compat` slot, per-api interpretation)
63
+ "supportsEagerToolInputStreaming?": "boolean",
63
64
  "requiresToolResultId?": "boolean",
64
65
  "replayUnsignedThinking?": "boolean",
65
66
  } as const;
@@ -4479,7 +4479,8 @@ export const SETTINGS_SCHEMA = {
4479
4479
  {
4480
4480
  value: "auto",
4481
4481
  label: "Auto",
4482
- description: "Priority: GPT model image tool > Antigravity > xAI > OpenRouter > Gemini",
4482
+ description:
4483
+ "Priority: active session provider > GPT model image tool > Antigravity > xAI > OpenRouter > Gemini",
4483
4484
  },
4484
4485
  { value: "openai", label: "OpenAI", description: "Uses the active GPT Responses/Codex model" },
4485
4486
  {
@@ -37,6 +37,7 @@ import {
37
37
  SOURCE_PATHS,
38
38
  scanSkillsFromDir,
39
39
  } from "./helpers";
40
+ import { resolvePluginStdioPaths } from "./substitute-plugin-root";
40
41
 
41
42
  const PROVIDER_ID = "codex";
42
43
  const DISPLAY_NAME = "OpenAI Codex";
@@ -87,7 +88,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
87
88
 
88
89
  const items: MCPServer[] = [];
89
90
  if (userConfig) {
90
- const servers = extractMCPServersFromToml(userConfig);
91
+ const servers = extractMCPServersFromToml(userConfig, path.dirname(userConfigPath));
91
92
  for (const [name, config] of Object.entries(servers)) {
92
93
  items.push({
93
94
  name,
@@ -97,7 +98,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
97
98
  }
98
99
  }
99
100
  if (projectConfig) {
100
- const servers = extractMCPServersFromToml(projectConfig);
101
+ const servers = extractMCPServersFromToml(projectConfig, path.dirname(projectConfigPath));
101
102
  for (const [name, config] of Object.entries(servers)) {
102
103
  items.push({
103
104
  name,
@@ -139,7 +140,10 @@ interface CodexMCPConfig {
139
140
  disabled_tools?: string[];
140
141
  }
141
142
 
142
- function extractMCPServersFromToml(toml: Record<string, unknown>): Record<string, Partial<MCPServer>> {
143
+ function extractMCPServersFromToml(
144
+ toml: Record<string, unknown>,
145
+ configDir: string,
146
+ ): Record<string, Partial<MCPServer>> {
143
147
  // Check for [mcp_servers.*] sections (Codex format)
144
148
  if (!toml.mcp_servers || typeof toml.mcp_servers !== "object") {
145
149
  return {};
@@ -149,10 +153,16 @@ function extractMCPServersFromToml(toml: Record<string, unknown>): Record<string
149
153
  const result: Record<string, Partial<MCPServer>> = {};
150
154
 
151
155
  for (const [name, config] of Object.entries(codexServers)) {
156
+ // Root relative cwd/command against the Codex config directory. Codex
157
+ // spawns the process with the resolved cwd, so a relative command is
158
+ // resolved by the OS from there — pass "cwd" so e.g. cwd="server",
159
+ // command="./bin/mcp" resolves to <configDir>/server/bin/mcp.
160
+ const rooted = resolvePluginStdioPaths({ command: config.command, cwd: config.cwd }, configDir, "cwd");
152
161
  const server: Partial<MCPServer> = {
153
- command: config.command,
162
+ ...(rooted.command !== undefined && { command: rooted.command }),
154
163
  args: config.args,
155
164
  url: config.url,
165
+ ...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
156
166
  };
157
167
 
158
168
  // Build env by merging explicit env and forwarded env_vars
@@ -31,23 +31,38 @@ export function substitutePluginRoot<T>(value: T, rootPath: string): T {
31
31
  }
32
32
 
33
33
  /**
34
- * Rebase relative filesystem values in a discovered plugin stdio config against
35
- * the directory of the `.mcp.json` that declared them.
34
+ * Where a relative, path-like `command` is rooted by {@link resolvePluginStdioPaths}.
36
35
  *
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
36
+ * - `"config-dir"` (default): the directory of the config file that declared the
37
+ * server the plugin package root for `.mcp.json`. A plugin can ship its
38
+ * executable at the package root (`command: "./bin/server"`) yet run from a
39
+ * data subdir (`cwd: "work"`); the command stays `<pkg>/bin/server`.
40
+ * - `"cwd"`: the rooted `cwd`, falling back to the config dir when no `cwd` is
41
+ * set. This matches how the OS resolves a relative command against the
42
+ * subprocess's working directory, which is the Codex `config.toml` contract:
43
+ * `cwd = "server"`, `command = "./bin/mcp"` → `<configDir>/server/bin/mcp`.
44
+ */
45
+ export type StdioCommandBase = "config-dir" | "cwd";
46
+
47
+ /**
48
+ * Rebase relative filesystem values in a discovered stdio server config against
49
+ * the directory of the config file (`.mcp.json`/`config.toml`) that declared them.
50
+ *
51
+ * External configs (bundled ChatGPT/Codex plugins, Claude marketplace plugins)
52
+ * express `command`/`cwd` relative to their own config file, but MCP stdio
53
+ * spawning roots relative values at the session cwd — so a server shipping
40
54
  * `command: "./bin/server"`, `cwd: "."` launches from the wrong directory and
41
55
  * fails with ENOENT. This resolves those against `configDir` instead:
42
56
  *
43
57
  * - relative `cwd` → resolved against `configDir`;
44
58
  * - path-like `command` (`./`, `../`, or the Windows `.\`/`..\` forms) →
45
- * resolved against `configDir`;
59
+ * resolved against the base selected by `commandBase` (see {@link StdioCommandBase});
46
60
  * - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched.
47
61
  */
48
62
  export function resolvePluginStdioPaths(
49
63
  config: { command?: string; cwd?: string },
50
64
  configDir: string,
65
+ commandBase: StdioCommandBase = "config-dir",
51
66
  ): { command?: string; cwd?: string } {
52
67
  const resolved: { command?: string; cwd?: string } = {};
53
68
  if (typeof config.cwd === "string") {
@@ -55,7 +70,8 @@ export function resolvePluginStdioPaths(
55
70
  }
56
71
  if (config.command !== undefined) {
57
72
  const isPathLike = /^\.\.?[/\\]/.test(config.command);
58
- resolved.command = isPathLike ? path.resolve(configDir, config.command) : config.command;
73
+ const base = commandBase === "cwd" ? (resolved.cwd ?? configDir) : configDir;
74
+ resolved.command = isPathLike ? path.resolve(base, config.command) : config.command;
59
75
  }
60
76
  return resolved;
61
77
  }
@@ -1082,6 +1082,8 @@
1082
1082
  <div class="thinking-text">${escapeHtml(thinking)}</div>
1083
1083
  <div class="thinking-collapsed">Thinking ...</div>
1084
1084
  </div>`;
1085
+ } else if (block.type === 'image') {
1086
+ html += `<div class="message-images"><img src="data:${block.mimeType};base64,${block.data}" class="message-image" /></div>`;
1085
1087
  }
1086
1088
  }
1087
1089
  for (const block of msg.content) {
@@ -66,3 +66,76 @@ it("agent:// resolves a depth-2 subagent's .md output while its session is live
66
66
  const resource = await new AgentProtocolHandler().resolve(new URL(`agent://${grandchildId}`) as never);
67
67
  expect(resource.content).toBe("full report content");
68
68
  });
69
+
70
+ it("agent:// slash form resolves a nested subagent child (hierarchy separator)", async () => {
71
+ const root = tempDir.path();
72
+ const rootSessionFile = path.join(root, "slash-session.jsonl");
73
+ const rootArtifactsDir = rootSessionFile.slice(0, -6);
74
+ await fs.mkdir(rootArtifactsDir, { recursive: true });
75
+ const sharedArtifactManager = new ArtifactManager(rootArtifactsDir);
76
+
77
+ // Parent subagent adopts the root ArtifactManager; its own children are
78
+ // written one level deeper under its sessionFile-derived dir, dot-qualified.
79
+ const parentSessionFile = path.join(rootArtifactsDir, "Parent.jsonl");
80
+ const parentOwnDir = parentSessionFile.slice(0, -6);
81
+ await fs.mkdir(parentOwnDir, { recursive: true });
82
+ await fs.writeFile(path.join(parentOwnDir, "Parent.Child.md"), "child capsule");
83
+ // Parent output may be in the root dir; the nested child must still win.
84
+ await fs.writeFile(path.join(rootArtifactsDir, "Parent.md"), JSON.stringify({ Child: "wrong base output" }));
85
+
86
+ const fakeSession = {
87
+ sessionManager: { getArtifactsDir: () => sharedArtifactManager.dir },
88
+ } as unknown as AgentSession;
89
+ const registry = AgentRegistry.global();
90
+ registry.register({
91
+ id: "Main",
92
+ displayName: "main",
93
+ kind: "main",
94
+ session: fakeSession,
95
+ sessionFile: rootSessionFile,
96
+ });
97
+ registry.register({
98
+ id: "Parent",
99
+ displayName: "sub",
100
+ kind: "sub",
101
+ parentId: "Main",
102
+ session: fakeSession,
103
+ sessionFile: parentSessionFile,
104
+ });
105
+
106
+ const handler = new AgentProtocolHandler();
107
+ // Slash form is a hierarchy hop, not a jq extraction.
108
+ const slash = await handler.resolve(new URL("agent://Parent/Child") as never);
109
+ expect(slash.content).toBe("child capsule");
110
+ expect(slash.contentType).toBe("text/markdown");
111
+ // The canonical dotted id resolves to the same output.
112
+ const dotted = await handler.resolve(new URL("agent://Parent.Child") as never);
113
+ expect(dotted.content).toBe("child capsule");
114
+ });
115
+
116
+ it("agent:// path form falls back to JSON extraction when no nested output matches", async () => {
117
+ const root = tempDir.path();
118
+ const rootSessionFile = path.join(root, "json-session.jsonl");
119
+ const rootArtifactsDir = rootSessionFile.slice(0, -6);
120
+ await fs.mkdir(rootArtifactsDir, { recursive: true });
121
+ const sharedArtifactManager = new ArtifactManager(rootArtifactsDir);
122
+ await fs.writeFile(path.join(rootArtifactsDir, "Worker.md"), JSON.stringify({ result: { ok: true } }));
123
+
124
+ const fakeSession = {
125
+ sessionManager: { getArtifactsDir: () => sharedArtifactManager.dir },
126
+ } as unknown as AgentSession;
127
+ const registry = AgentRegistry.global();
128
+ registry.register({
129
+ id: "Main",
130
+ displayName: "main",
131
+ kind: "main",
132
+ session: fakeSession,
133
+ sessionFile: rootSessionFile,
134
+ });
135
+
136
+ const handler = new AgentProtocolHandler();
137
+ // `result` names no nested output, so the path extracts JSON from Worker.md.
138
+ const extracted = await handler.resolve(new URL("agent://Worker/result") as never);
139
+ expect(extracted.contentType).toBe("application/json");
140
+ expect(JSON.parse(extracted.content)).toEqual({ ok: true });
141
+ });