@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.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 (121) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli.js +3336 -3318
  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/eval/__tests__/process-entry-import.test.d.ts +1 -0
  14. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  15. package/dist/types/eval/js/context-manager.d.ts +5 -3
  16. package/dist/types/eval/js/process-entry.d.ts +6 -0
  17. package/dist/types/eval/js/worker-core.d.ts +15 -1
  18. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  19. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  20. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  24. package/dist/types/main.d.ts +1 -0
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  27. package/dist/types/modes/orchestrate.d.ts +1 -1
  28. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  29. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  30. package/dist/types/modes/ultrathink.d.ts +1 -1
  31. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  32. package/dist/types/modes/workflow.d.ts +1 -1
  33. package/dist/types/session/agent-session.d.ts +6 -0
  34. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  35. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  36. package/dist/types/subprocess/worker-client.d.ts +6 -0
  37. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  38. package/dist/types/web/search/provider.d.ts +10 -3
  39. package/dist/types/web/search/providers/codex.d.ts +5 -4
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +830 -42
  42. package/src/advisor/advise-tool.ts +17 -1
  43. package/src/advisor/runtime.ts +288 -67
  44. package/src/autolearn/controller.ts +15 -3
  45. package/src/cli/args.ts +12 -0
  46. package/src/cli/auth-broker-cli.ts +30 -11
  47. package/src/cli/auth-gateway-cli.ts +5 -1
  48. package/src/cli/dry-balance-cli.ts +14 -4
  49. package/src/cli/flag-tables.ts +21 -7
  50. package/src/cli/update-cli.ts +62 -11
  51. package/src/cli/usage-cli.ts +58 -5
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/cli.ts +23 -1
  54. package/src/commands/acp.ts +11 -2
  55. package/src/commands/launch.ts +12 -3
  56. package/src/commands/token.ts +3 -1
  57. package/src/config/api-key-resolver.ts +12 -3
  58. package/src/config/config-file.ts +30 -12
  59. package/src/config/model-registry.ts +7 -7
  60. package/src/config/model-resolver.ts +21 -7
  61. package/src/config/models-config.ts +1 -1
  62. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  63. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  64. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  65. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  66. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  67. package/src/eval/agent-bridge.ts +121 -116
  68. package/src/eval/bridge-timeout.ts +20 -2
  69. package/src/eval/executor-base.ts +85 -7
  70. package/src/eval/jl/kernel.ts +2 -1
  71. package/src/eval/js/context-manager.ts +109 -32
  72. package/src/eval/js/process-entry.ts +27 -0
  73. package/src/eval/js/shared/runtime.ts +1 -1
  74. package/src/eval/js/worker-core.ts +70 -9
  75. package/src/eval/js/worker-entry.ts +1 -1
  76. package/src/eval/py/kernel.ts +2 -1
  77. package/src/eval/py/spawn-options.ts +13 -0
  78. package/src/eval/py/tool-bridge.ts +13 -14
  79. package/src/eval/rb/kernel.ts +2 -1
  80. package/src/extensibility/custom-tools/types.ts +3 -0
  81. package/src/extensibility/extensions/runner.ts +3 -0
  82. package/src/extensibility/extensions/types.ts +3 -0
  83. package/src/extensibility/plugins/manager.ts +21 -0
  84. package/src/internal-urls/memory-protocol.ts +13 -9
  85. package/src/lsp/client.ts +7 -1
  86. package/src/main.ts +29 -0
  87. package/src/mcp/tool-bridge.ts +57 -6
  88. package/src/modes/components/chat-transcript-builder.ts +22 -1
  89. package/src/modes/components/status-line/component.ts +10 -1
  90. package/src/modes/components/transcript-container.ts +110 -7
  91. package/src/modes/controllers/command-controller.ts +12 -4
  92. package/src/modes/controllers/event-controller.ts +80 -15
  93. package/src/modes/controllers/selector-controller.ts +15 -3
  94. package/src/modes/magic-keyword-boundary.ts +23 -0
  95. package/src/modes/orchestrate.ts +6 -5
  96. package/src/modes/print-mode.ts +9 -0
  97. package/src/modes/rpc/host-tools.ts +15 -0
  98. package/src/modes/rpc/rpc-mode.ts +123 -48
  99. package/src/modes/ultrathink.ts +6 -5
  100. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  101. package/src/modes/utils/ui-helpers.ts +27 -1
  102. package/src/modes/workflow.ts +6 -5
  103. package/src/prompts/advisor/system.md +1 -0
  104. package/src/sdk.ts +33 -3
  105. package/src/session/agent-session.ts +239 -17
  106. package/src/session/exit-diagnostics.ts +108 -0
  107. package/src/session/streaming-output.ts +40 -12
  108. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  109. package/src/slash-commands/helpers/logout.ts +23 -3
  110. package/src/slash-commands/helpers/usage-report.ts +14 -2
  111. package/src/subprocess/worker-client.ts +9 -2
  112. package/src/task/executor.ts +8 -0
  113. package/src/task/render.test.ts +36 -0
  114. package/src/task/render.ts +55 -43
  115. package/src/tools/bash-skill-urls.ts +4 -1
  116. package/src/tools/bash.ts +1 -0
  117. package/src/tools/write.ts +82 -9
  118. package/src/tools/yield.ts +29 -1
  119. package/src/web/search/index.ts +39 -22
  120. package/src/web/search/provider.ts +33 -16
  121. package/src/web/search/providers/codex.ts +68 -21
@@ -83,27 +83,96 @@ const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
83
83
  const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
84
84
 
85
85
  const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
86
+ /**
87
+ * The head of a per-id directive line — `<id>:` / `<id>=` (optionally `#`-prefixed),
88
+ * regardless of whether its value is a valid `@side` token. Used only to sharpen the
89
+ * error message when a directive block is malformed (e.g. `15: some literal text`).
90
+ */
91
+ const BULK_DIRECTIVE_HEAD_RE = /^#?\d+\s*[:=]/;
92
+
93
+ function truncateDirectiveLine(line: string): string {
94
+ return line.length > 60 ? `${line.slice(0, 57)}…` : line;
95
+ }
86
96
 
87
97
  /**
88
98
  * Parse `conflict://*` per-id directive content: every non-empty line must be
89
- * `<id>: @side` (also accepted: `#<id> = @side`). Returns `null` when the
90
- * content is not directive-shaped (→ uniform bulk mode); throws on duplicate
91
- * ids so a typo never silently drops a resolution.
99
+ * `<id>: @side` (also accepted: `#<id> = @side`), where `@side` is one of
100
+ * `@ours` / `@theirs` / `@base` / `@both`.
101
+ *
102
+ * Returns `null` only when NO line is directive-shaped (→ uniform bulk mode).
103
+ * Throws on duplicate ids, and — critically — on a *partial* directive block:
104
+ * content that mixes valid `<id>: @side` lines with lines that aren't. Without
105
+ * that guard a per-id write carrying any non-token value (a literal or
106
+ * multi-line replacement, e.g. `15: <multi-line content>`) fell through to
107
+ * uniform bulk mode, which pasted the raw directive text verbatim into every
108
+ * block and still reported success. Per-id bulk is token-only; literal or
109
+ * multi-line replacements must go through individual `conflict://<N>` writes.
92
110
  */
93
111
  function parseBulkDirectives(content: string): Map<number, string> | null {
94
112
  const map = new Map<number, string>();
113
+ const stray: string[] = [];
114
+ let sawDirective = false;
95
115
  for (const raw of content.split("\n")) {
96
116
  const line = raw.trim();
97
117
  if (line.length === 0) continue;
98
118
  const match = line.match(BULK_DIRECTIVE_RE);
99
- if (!match) return null;
119
+ if (!match) {
120
+ stray.push(line);
121
+ continue;
122
+ }
123
+ sawDirective = true;
100
124
  const id = Number.parseInt(match[1], 10);
101
125
  if (map.has(id)) {
102
126
  throw new ToolError(`Bulk directive lists conflict #${id} twice — each id may appear once.`);
103
127
  }
104
128
  map.set(id, match[2]);
105
129
  }
106
- return map.size > 0 ? map : null;
130
+ // No directive lines at all not a per-id block; caller uses uniform mode.
131
+ if (!sawDirective) return null;
132
+ if (stray.length > 0) {
133
+ const sample = stray[0]!;
134
+ const tokenHint = BULK_DIRECTIVE_HEAD_RE.test(sample)
135
+ ? `Per-id bulk only accepts the tokens @ours/@theirs/@base/@both — one side per id, single line. `
136
+ : "";
137
+ throw new ToolError(
138
+ `Malformed \`conflict://*\` per-id block: ${stray.length} line(s) are not \`<id>: @side\` directives (first: \`${truncateDirectiveLine(sample)}\`). ` +
139
+ tokenHint +
140
+ `Literal or multi-line replacement content isn't supported in a per-id block — resolve those blocks with individual \`write({ path: "conflict://<N>", content })\` calls (you can issue several at once). ` +
141
+ `For a pure pick-a-side pass, make every non-empty line \`<id>: @ours\` (or @theirs/@base/@both).`,
142
+ );
143
+ }
144
+ return map;
145
+ }
146
+
147
+ /**
148
+ * Resolve per-id directives, preferring the pre-strip `raw` content and falling
149
+ * back to the hashline-stripped `stripped` content.
150
+ *
151
+ * Raw is preferred because the `<id>:` directive heads look exactly like
152
+ * hashline `LINE:` prefixes and would be eaten by stripping. When the two
153
+ * contents are identical (hashline mode off) a single parse decides everything,
154
+ * so a malformed-block error propagates straight through — the previous
155
+ * `?? parseBulkDirectives(...)` chain would have swallowed it and silently
156
+ * degraded to uniform bulk mode, pasting the raw directive text into every
157
+ * block. When they differ, a malformed raw block still defers to a *clean*
158
+ * stripped block, but otherwise surfaces its error rather than degrading.
159
+ */
160
+ function resolveBulkDirectives(raw: string, stripped: string): Map<number, string> | null {
161
+ if (raw === stripped) return parseBulkDirectives(raw);
162
+ let rawResult: Map<number, string> | null;
163
+ try {
164
+ rawResult = parseBulkDirectives(raw);
165
+ } catch (rawError) {
166
+ let fallback: Map<number, string> | null = null;
167
+ try {
168
+ fallback = parseBulkDirectives(stripped);
169
+ } catch {
170
+ fallback = null;
171
+ }
172
+ if (fallback) return fallback;
173
+ throw rawError;
174
+ }
175
+ return rawResult ?? parseBulkDirectives(stripped);
107
176
  }
108
177
 
109
178
  const writeSchema = type({
@@ -716,7 +785,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
716
785
  // winner — one call instead of one write per conflict. Parsed from the
717
786
  // PRE-strip content: hashline prefix stripping would otherwise eat the
718
787
  // `<id>: ` heads as echoed line numbers.
719
- const directives = parseBulkDirectives(rawContent) ?? parseBulkDirectives(replacementContent);
788
+ const directives = resolveBulkDirectives(rawContent, replacementContent);
720
789
  if (directives) {
721
790
  const known = new Set(allEntries.map(entry => entry.id));
722
791
  const unknown = [...directives.keys()].filter(id => !known.has(id));
@@ -898,9 +967,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
898
967
  }
899
968
  return { content: [{ type: "text", text: resultText }], details: {} };
900
969
  }
901
- // Schemes without a `write` hook fall through to existing logic
902
- // (local:// resolves to a backing file via plan-mode-guard) or are
903
- // rejected downstream when no backing file exists.
970
+ if (scheme !== "local") {
971
+ throw new ToolError(
972
+ `${scheme}:// URLs are read-only for write; use the protocol-specific tool for mutations.`,
973
+ );
974
+ }
975
+ // local:// is backed by the session-local artifact sandbox and is
976
+ // resolved by resolvePlanPath below so write/read share the same root.
904
977
  }
905
978
 
906
979
  const conflictUri = parseConflictUri(path);
@@ -198,6 +198,14 @@ function wrapYieldParameters(dataSchema: Record<string, unknown>): Record<string
198
198
  */
199
199
  const MAX_SCHEMA_RETRIES = 3;
200
200
 
201
+ /**
202
+ * Max consecutive untyped empty-result submissions before the yield tool fails
203
+ * the child explicitly. Some weak tool callers can acknowledge the required
204
+ * wrapper in prose while repeatedly sending `{ result: {} }`; without a hard
205
+ * stop the parent waits forever.
206
+ */
207
+ const MAX_EMPTY_RESULT_RETRIES = 3;
208
+
201
209
  export class YieldTool implements AgentTool<TSchema, YieldDetails> {
202
210
  readonly name = "yield";
203
211
  readonly approval = "read" as const;
@@ -217,6 +225,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
217
225
  #knownSectionLabels: readonly string[] = [];
218
226
  #isKnownSection?: (label: string) => boolean;
219
227
  #schemaValidationFailures = 0;
228
+ #emptyResultFailures = 0;
220
229
 
221
230
  constructor(session: ToolSession) {
222
231
  let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined;
@@ -322,8 +331,26 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
322
331
  throw new Error("result cannot contain both data and error");
323
332
  }
324
333
  if (errorMessage === undefined && data === undefined && yieldType === undefined) {
334
+ this.#emptyResultFailures++;
335
+ if (this.#emptyResultFailures > MAX_EMPTY_RESULT_RETRIES) {
336
+ const attemptCount = this.#emptyResultFailures;
337
+ this.#emptyResultFailures = 0;
338
+ const error =
339
+ `yield result stayed empty after ${attemptCount} consecutive attempt(s); aborting child instead of retrying forever. ` +
340
+ 'Submit success as `{ "result": { "data": <your output> } }` or failure as `{ "result": { "error": "message" } }`.';
341
+ return {
342
+ content: [{ type: "text", text: `Task aborted: ${error}` }],
343
+ details: {
344
+ data: undefined,
345
+ status: "aborted",
346
+ error,
347
+ type: yieldType,
348
+ },
349
+ };
350
+ }
351
+ const remaining = MAX_EMPTY_RESULT_RETRIES - this.#emptyResultFailures;
325
352
  throw new Error(
326
- 'result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: "message"}}` for failure.',
353
+ `result must contain either \`data\` or \`error\`. Use \`{result: {data: <your output>}}\` for success or \`{result: {error: "message"}}\` for failure. Empty untyped result retries remaining before abort: ${remaining}.`,
327
354
  );
328
355
  }
329
356
 
@@ -370,6 +397,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
370
397
  }
371
398
  }
372
399
 
400
+ this.#emptyResultFailures = 0;
373
401
  const responseText =
374
402
  status === "aborted"
375
403
  ? `Task aborted: ${errorMessage}`
@@ -21,8 +21,10 @@ import {
21
21
  formatSearchProviderFailure,
22
22
  formatSearchProviderFailures,
23
23
  getSearchProvider,
24
- resolveProviderChain,
24
+ getSearchProviderLabel,
25
+ resolveProviderCandidates,
25
26
  type SearchProvider,
27
+ type SearchProviderCandidate,
26
28
  } from "./provider";
27
29
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
28
30
  import type { SearchProviderId, SearchResponse } from "./types";
@@ -128,25 +130,18 @@ async function executeSearch(
128
130
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
129
131
  const { authStorage, sessionId, signal } = options;
130
132
  const explicitProvider = params.provider;
131
- let providers: SearchProvider[];
133
+ let candidates: SearchProviderCandidate[];
132
134
  if (explicitProvider && explicitProvider !== "auto") {
133
135
  const provider = await getSearchProvider(explicitProvider);
134
- providers = (await provider.isExplicitlyAvailable(authStorage))
135
- ? [provider]
136
- : await resolveProviderChain(authStorage, "auto");
136
+ candidates = (await provider.isExplicitlyAvailable(authStorage))
137
+ ? [{ id: explicitProvider, explicit: true }]
138
+ : resolveProviderCandidates("auto");
137
139
  } else if (explicitProvider === "auto") {
138
140
  // Explicit `--provider auto` bypasses the configured preferred provider
139
141
  // for this invocation; exclusions still apply.
140
- providers = await resolveProviderChain(authStorage, "auto");
142
+ candidates = resolveProviderCandidates("auto");
141
143
  } else {
142
- providers = await resolveProviderChain(authStorage);
143
- }
144
- if (providers.length === 0) {
145
- const message = "No web search provider configured.";
146
- return {
147
- content: [{ type: "text" as const, text: `Error: ${message}` }],
148
- details: { response: { provider: "none", sources: [] }, error: message },
149
- };
144
+ candidates = resolveProviderCandidates();
150
145
  }
151
146
 
152
147
  // Invariant across providers; read once and tolerate an uninitialized
@@ -166,11 +161,22 @@ async function executeSearch(
166
161
  geminiModel = undefined;
167
162
  }
168
163
 
169
- const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
170
- let lastProvider = providers[0];
171
- for (const provider of providers) {
172
- lastProvider = provider;
164
+ const failures: Array<{ provider: Pick<SearchProvider, "id" | "label">; error: unknown }> = [];
165
+ let availableProviderCount = 0;
166
+ let lastProvider: Pick<SearchProvider, "id" | "label"> | undefined;
167
+ for (const candidate of candidates) {
168
+ let provider: SearchProvider | undefined;
169
+ const providerMeta = { id: candidate.id, label: getSearchProviderLabel(candidate.id) };
170
+ lastProvider = providerMeta;
173
171
  try {
172
+ provider = await getSearchProvider(candidate.id);
173
+ const available = candidate.explicit
174
+ ? await provider.isExplicitlyAvailable(authStorage)
175
+ : await provider.isAvailable(authStorage);
176
+ if (!available) continue;
177
+ availableProviderCount++;
178
+ lastProvider = provider;
179
+
174
180
  const response = await provider.search({
175
181
  query: params.query,
176
182
  limit: params.limit,
@@ -203,20 +209,31 @@ async function executeSearch(
203
209
  // failure and the loop falls through to the next provider (or to the
204
210
  // summary error), masking the cancellation.
205
211
  throwIfAborted(signal);
206
- failures.push({ provider, error });
212
+ failures.push({ provider: provider ?? providerMeta, error });
207
213
  }
208
214
  }
209
215
 
216
+ if (availableProviderCount === 0 && failures.length === 0) {
217
+ const message = "No web search provider configured.";
218
+ return {
219
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
220
+ details: { response: { provider: "none", sources: [] }, error: message },
221
+ };
222
+ }
223
+
210
224
  const lastFailure = failures[failures.length - 1];
211
225
  const baseMessage = lastFailure
212
226
  ? formatSearchProviderFailure(lastFailure.error, lastFailure.provider)
213
- : `Unknown error from ${lastProvider.label}`;
227
+ : `Unknown error from ${lastProvider?.label ?? "web search provider"}`;
214
228
  const message =
215
- providers.length > 1 ? `All web search providers failed: ${formatSearchProviderFailures(failures)}` : baseMessage;
229
+ failures.length > 1 ? `All web search providers failed: ${formatSearchProviderFailures(failures)}` : baseMessage;
216
230
 
217
231
  return {
218
232
  content: [{ type: "text" as const, text: `Error: ${message}` }],
219
- details: { response: { provider: lastProvider.id, sources: [] }, error: message },
233
+ details: {
234
+ response: { provider: lastFailure?.provider.id ?? lastProvider?.id ?? "none", sources: [] },
235
+ error: message,
236
+ },
220
237
  };
221
238
  }
222
239
 
@@ -210,10 +210,34 @@ export function isSearchProviderExcluded(id: SearchProviderId): boolean {
210
210
  return excludedProvIds.has(id);
211
211
  }
212
212
 
213
+ export interface SearchProviderCandidate {
214
+ id: SearchProviderId;
215
+ explicit: boolean;
216
+ }
217
+
218
+ /** Return provider candidates in fallback order without loading their modules. */
219
+ export function resolveProviderCandidates(
220
+ preferredProvider: SearchProviderId | "auto" = preferredProvId,
221
+ ): SearchProviderCandidate[] {
222
+ const candidates: SearchProviderCandidate[] = [];
223
+
224
+ if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
225
+ candidates.push({ id: preferredProvider, explicit: true });
226
+ }
227
+
228
+ for (const id of SEARCH_PROVIDER_ORDER) {
229
+ if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
230
+ candidates.push({ id, explicit: false });
231
+ }
232
+
233
+ return candidates;
234
+ }
235
+
213
236
  /**
214
- * Determine which providers are configured and currently available.
215
- * Each candidate is loaded (and its `isAvailable()` called) only as the chain
216
- * is walked, so unconfigured providers never pay the load cost.
237
+ * Resolve the complete available provider chain.
238
+ *
239
+ * This compatibility helper loads every candidate. Search execution should use
240
+ * {@link resolveProviderCandidates} so fallback modules load only when reached.
217
241
  */
218
242
  export async function resolveProviderChain(
219
243
  authStorage: AuthStorage,
@@ -221,19 +245,12 @@ export async function resolveProviderChain(
221
245
  ): Promise<SearchProvider[]> {
222
246
  const providers: SearchProvider[] = [];
223
247
 
224
- if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
225
- const provider = await getSearchProvider(preferredProvider);
226
- if (await provider.isExplicitlyAvailable(authStorage)) {
227
- providers.push(provider);
228
- }
229
- }
230
-
231
- for (const id of SEARCH_PROVIDER_ORDER) {
232
- if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
233
- const provider = await getSearchProvider(id);
234
- if (await provider.isAvailable(authStorage)) {
235
- providers.push(provider);
236
- }
248
+ for (const candidate of resolveProviderCandidates(preferredProvider)) {
249
+ const provider = await getSearchProvider(candidate.id);
250
+ const available = candidate.explicit
251
+ ? await provider.isExplicitlyAvailable(authStorage)
252
+ : await provider.isAvailable(authStorage);
253
+ if (available) providers.push(provider);
237
254
  }
238
255
 
239
256
  return providers;
@@ -7,9 +7,12 @@
7
7
  * SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint.
8
8
  */
9
9
  import * as os from "node:os";
10
- import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
10
+ import { type AuthStorage, type FetchImpl, type Model, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
11
11
  import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex";
12
+ import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
13
+ import { createOpenAICodexCompatibilityMetadata } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
12
14
  import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
15
+ import { CODEX_CLIENT_VERSION, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "@oh-my-pi/pi-catalog/wire/codex";
13
16
  import { $env, readSseJson } from "@oh-my-pi/pi-utils";
14
17
  import packageJson from "../../../../package.json" with { type: "json" };
15
18
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
@@ -22,6 +25,9 @@ const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
22
25
  const CODEX_RESPONSES_PATH = "/codex/responses";
23
26
  const FALLBACK_MODEL = "gpt-5.5";
24
27
  const DEFAULT_MODEL_PREFERENCES = [
28
+ "gpt-5.6-luna",
29
+ "gpt-5.6-terra",
30
+ "gpt-5.6-sol",
25
31
  "gpt-5.5",
26
32
  "gpt-5.4",
27
33
  "gpt-5-codex",
@@ -35,15 +41,38 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
35
41
  const DEFAULT_INSTRUCTIONS =
36
42
  "You are a helpful assistant with web search capabilities. Search the web to answer the user's question accurately and cite your sources.";
37
43
 
38
- function getConfiguredModel(): string | undefined {
44
+ type CodexSearchModel = Model<"openai-codex-responses">;
45
+
46
+ interface CodexModelCandidate {
47
+ modelId: string;
48
+ catalogModel?: CodexSearchModel;
49
+ }
50
+
51
+ function getBundledCodexModels(): CodexSearchModel[] {
52
+ const models: CodexSearchModel[] = [];
53
+ for (const model of getBundledModels("openai-codex")) {
54
+ if (model.api === "openai-codex-responses") {
55
+ models.push(model as CodexSearchModel);
56
+ }
57
+ }
58
+ return models;
59
+ }
60
+
61
+ function getConfiguredModel(): CodexModelCandidate | undefined {
39
62
  const configuredModel = $env.PI_CODEX_WEB_SEARCH_MODEL?.trim();
40
- return configuredModel ? configuredModel : undefined;
63
+ if (!configuredModel) return undefined;
64
+
65
+ const catalogModel = getBundledCodexModels().find(model => model.id === configuredModel);
66
+ return { modelId: configuredModel, ...(catalogModel ? { catalogModel } : {}) };
41
67
  }
42
68
 
43
- function getDefaultModelCandidates(): string[] {
44
- const bundledModels = getBundledModels("openai-codex");
45
- const bundledIds = new Set(bundledModels.map(model => model.id));
46
- const candidates = DEFAULT_MODEL_PREFERENCES.filter(modelId => bundledIds.has(modelId));
69
+ function getDefaultModelCandidates(): CodexModelCandidate[] {
70
+ const bundledModels = getBundledCodexModels();
71
+ const candidates: CodexModelCandidate[] = [];
72
+ for (const modelId of DEFAULT_MODEL_PREFERENCES) {
73
+ const catalogModel = bundledModels.find(model => model.id === modelId);
74
+ if (catalogModel) candidates.push({ modelId, catalogModel });
75
+ }
47
76
 
48
77
  if (candidates.length > 0) {
49
78
  return candidates;
@@ -51,10 +80,11 @@ function getDefaultModelCandidates(): string[] {
51
80
 
52
81
  const nonMini = bundledModels.find(model => !model.id.includes("mini") && !model.id.includes("spark"));
53
82
  if (nonMini) {
54
- return [nonMini.id];
83
+ return [{ modelId: nonMini.id, catalogModel: nonMini }];
55
84
  }
56
85
 
57
- return bundledModels[0]?.id ? [bundledModels[0].id] : [FALLBACK_MODEL];
86
+ const fallbackModel = bundledModels[0];
87
+ return fallbackModel ? [{ modelId: fallbackModel.id, catalogModel: fallbackModel }] : [{ modelId: FALLBACK_MODEL }];
58
88
  }
59
89
 
60
90
  function shouldRetryWithNextDefaultModel(error: unknown): boolean {
@@ -301,9 +331,10 @@ async function findCodexAuth(
301
331
  function buildCodexHeaders(accessToken: string, accountId: string): Record<string, string> {
302
332
  return {
303
333
  Authorization: `Bearer ${accessToken}`,
304
- "chatgpt-account-id": accountId,
305
- "OpenAI-Beta": "responses=experimental",
306
- originator: "pi",
334
+ [OPENAI_HEADERS.ACCOUNT_ID]: accountId,
335
+ [OPENAI_HEADERS.BETA]: OPENAI_HEADER_VALUES.BETA_RESPONSES,
336
+ [OPENAI_HEADERS.ORIGINATOR]: OPENAI_HEADER_VALUES.ORIGINATOR_CODEX,
337
+ [OPENAI_HEADERS.VERSION]: CODEX_CLIENT_VERSION,
307
338
  "User-Agent": `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`,
308
339
  Accept: "text/event-stream",
309
340
  "Content-Type": "application/json",
@@ -323,7 +354,8 @@ async function callCodexSearch(
323
354
  signal?: AbortSignal;
324
355
  systemPrompt?: string;
325
356
  searchContextSize?: "low" | "medium" | "high";
326
- modelId: string;
357
+ model: CodexModelCandidate;
358
+ sessionId?: string;
327
359
  fetch?: FetchImpl;
328
360
  },
329
361
  ): Promise<{
@@ -336,7 +368,8 @@ async function callCodexSearch(
336
368
  const url = `${CODEX_BASE_URL}${CODEX_RESPONSES_PATH}`;
337
369
  const headers = buildCodexHeaders(auth.accessToken, auth.accountId);
338
370
 
339
- const requestedModel = options.modelId;
371
+ const requestedModel = options.model.modelId;
372
+ const usesResponsesLite = options.model.catalogModel?.useResponsesLite === true;
340
373
 
341
374
  const body: Record<string, unknown> = {
342
375
  model: requestedModel,
@@ -358,6 +391,18 @@ async function callCodexSearch(
358
391
  tool_choice: { type: "web_search" },
359
392
  instructions: options.systemPrompt ?? DEFAULT_INSTRUCTIONS,
360
393
  };
394
+ if (usesResponsesLite) {
395
+ const metadata = createOpenAICodexCompatibilityMetadata({
396
+ sessionId: options.sessionId,
397
+ requestKind: "turn",
398
+ startNewTurn: true,
399
+ });
400
+ Object.assign(headers, metadata.headers);
401
+ headers[OPENAI_HEADERS.RESPONSES_LITE] = "true";
402
+ body.client_metadata = metadata.clientMetadata;
403
+ body.reasoning = { context: "all_turns" };
404
+ applyCodexResponsesLiteShape(body);
405
+ }
361
406
 
362
407
  const fetchImpl = options.fetch ?? fetch;
363
408
  const response = await fetchImpl(url, {
@@ -489,10 +534,11 @@ async function callCodexSearch(
489
534
  * Default-model behavior:
490
535
  * - If `PI_CODEX_WEB_SEARCH_MODEL` is set, use it exactly once and surface any
491
536
  * upstream error verbatim.
492
- * - Otherwise prefer ChatGPT-account-safe bundled defaults (GPT-5.4, GPT-5
493
- * Codex, GPT-5, …) and retry the next candidate only when Codex returns the
494
- * known 400 "model is not supported" family. This avoids selecting
495
- * `gpt-5-codex-mini` first on ChatGPT accounts, which OpenAI rejects.
537
+ * - Otherwise prefer ChatGPT-account-safe bundled defaults (GPT-5.6 Luna,
538
+ * Terra, Sol, GPT-5.5, …) and retry the next candidate only when Codex
539
+ * returns the known 400 "model is not supported" family. This avoids
540
+ * selecting `gpt-5-codex-mini` first on ChatGPT accounts, which OpenAI
541
+ * rejects.
496
542
  */
497
543
  export async function searchCodex(params: SearchParams): Promise<SearchResponse> {
498
544
  const seed = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
@@ -520,15 +566,16 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
520
566
 
521
567
  let lastError: unknown;
522
568
  for (let index = 0; index < modelCandidates.length; index += 1) {
523
- const modelId = modelCandidates[index];
524
- if (!modelId) continue;
569
+ const candidate = modelCandidates[index];
570
+ if (!candidate) continue;
525
571
 
526
572
  try {
527
573
  return await callCodexSearch(auth, params.query, {
528
574
  signal: params.signal,
529
575
  systemPrompt: params.systemPrompt,
530
576
  searchContextSize: "high",
531
- modelId,
577
+ model: candidate,
578
+ sessionId: params.sessionId,
532
579
  fetch: params.fetch,
533
580
  });
534
581
  } catch (error) {