@oh-my-pi/pi-coding-agent 16.2.12 → 16.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +115 -7
  2. package/dist/cli.js +5996 -5945
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/models-config-schema.d.ts +12 -0
  9. package/dist/types/config/models-config.d.ts +9 -0
  10. package/dist/types/config/settings-schema.d.ts +28 -5
  11. package/dist/types/edit/modes/patch.d.ts +11 -0
  12. package/dist/types/eval/agent-bridge.d.ts +5 -1
  13. package/dist/types/exec/bash-executor.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  15. package/dist/types/extensibility/skills.d.ts +2 -1
  16. package/dist/types/mnemopi/state.d.ts +12 -0
  17. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  18. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  19. package/dist/types/modes/components/usage-row.d.ts +1 -1
  20. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  22. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  23. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  24. package/dist/types/modes/interactive-mode.d.ts +12 -7
  25. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  26. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  27. package/dist/types/modes/session-teardown.d.ts +63 -0
  28. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  29. package/dist/types/modes/theme/theme.d.ts +9 -4
  30. package/dist/types/modes/types.d.ts +2 -3
  31. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  32. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  33. package/dist/types/sdk.d.ts +9 -0
  34. package/dist/types/session/agent-session.d.ts +46 -13
  35. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  36. package/dist/types/session/session-loader.d.ts +5 -0
  37. package/dist/types/task/executor.d.ts +12 -5
  38. package/dist/types/task/parallel.d.ts +1 -0
  39. package/dist/types/task/render.test.d.ts +1 -0
  40. package/dist/types/thinking.d.ts +2 -0
  41. package/dist/types/tools/checkpoint.d.ts +8 -0
  42. package/dist/types/tools/eval-render.d.ts +1 -0
  43. package/dist/types/tools/fetch.d.ts +11 -1
  44. package/dist/types/tools/index.d.ts +3 -1
  45. package/dist/types/tools/irc.d.ts +1 -0
  46. package/dist/types/tools/output-meta.d.ts +7 -0
  47. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  48. package/dist/types/tools/path-utils.d.ts +16 -0
  49. package/dist/types/tools/render-utils.d.ts +4 -1
  50. package/dist/types/utils/git.d.ts +47 -2
  51. package/dist/types/web/search/provider.d.ts +7 -0
  52. package/dist/types/web/search/types.d.ts +1 -1
  53. package/package.json +12 -12
  54. package/src/advisor/config.ts +4 -2
  55. package/src/cli/bench-cli.ts +7 -2
  56. package/src/collab/guest.ts +94 -5
  57. package/src/collab/host.ts +80 -3
  58. package/src/collab/protocol.ts +9 -2
  59. package/src/commit/model-selection.ts +8 -2
  60. package/src/config/keybindings.ts +3 -1
  61. package/src/config/model-discovery.ts +66 -2
  62. package/src/config/model-registry.ts +7 -3
  63. package/src/config/model-resolver.ts +51 -23
  64. package/src/config/models-config-schema.ts +13 -0
  65. package/src/config/settings-schema.ts +44 -14
  66. package/src/edit/hashline/diff.ts +13 -2
  67. package/src/edit/index.ts +58 -8
  68. package/src/edit/modes/patch.ts +53 -18
  69. package/src/edit/streaming.ts +7 -6
  70. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  71. package/src/eval/agent-bridge.ts +15 -5
  72. package/src/exec/bash-executor.ts +7 -12
  73. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  74. package/src/extensibility/skills.ts +29 -6
  75. package/src/goals/guided-setup.ts +4 -3
  76. package/src/internal-urls/docs-index.generated.txt +1 -1
  77. package/src/lsp/client.ts +11 -14
  78. package/src/main.ts +38 -10
  79. package/src/mnemopi/state.ts +43 -7
  80. package/src/modes/acp/acp-agent.ts +1 -1
  81. package/src/modes/components/agent-hub.ts +10 -2
  82. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  83. package/src/modes/components/assistant-message.ts +16 -10
  84. package/src/modes/components/chat-transcript-builder.ts +11 -1
  85. package/src/modes/components/custom-editor.test.ts +20 -0
  86. package/src/modes/components/hook-selector.ts +44 -25
  87. package/src/modes/components/model-selector.ts +1 -1
  88. package/src/modes/components/status-line/component.test.ts +44 -0
  89. package/src/modes/components/status-line/component.ts +9 -1
  90. package/src/modes/components/status-line/segments.ts +3 -3
  91. package/src/modes/components/usage-row.ts +16 -2
  92. package/src/modes/controllers/command-controller.ts +6 -21
  93. package/src/modes/controllers/event-controller.ts +11 -9
  94. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  95. package/src/modes/controllers/input-controller.ts +16 -13
  96. package/src/modes/controllers/selector-controller.ts +3 -8
  97. package/src/modes/controllers/streaming-reveal.ts +75 -53
  98. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  99. package/src/modes/interactive-mode.ts +122 -79
  100. package/src/modes/rpc/rpc-client.ts +21 -0
  101. package/src/modes/rpc/rpc-mode.ts +197 -46
  102. package/src/modes/session-teardown.test.ts +219 -0
  103. package/src/modes/session-teardown.ts +82 -0
  104. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  105. package/src/modes/skill-command.ts +7 -20
  106. package/src/modes/theme/theme.ts +29 -15
  107. package/src/modes/types.ts +2 -3
  108. package/src/modes/utils/copy-targets.ts +12 -0
  109. package/src/modes/utils/ui-helpers.ts +19 -2
  110. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  111. package/src/prompts/advisor/system.md +1 -1
  112. package/src/prompts/agents/tester.md +6 -2
  113. package/src/prompts/skills/autoload.md +8 -0
  114. package/src/prompts/skills/user-invocation.md +11 -0
  115. package/src/prompts/steering/parent-irc.md +5 -0
  116. package/src/prompts/system/irc-incoming.md +2 -0
  117. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  118. package/src/prompts/system/plan-mode-approved.md +7 -10
  119. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  120. package/src/prompts/system/plan-mode-reference.md +3 -4
  121. package/src/prompts/system/rewind-report.md +6 -0
  122. package/src/prompts/system/subagent-system-prompt.md +3 -0
  123. package/src/prompts/tools/irc.md +2 -1
  124. package/src/prompts/tools/job.md +2 -2
  125. package/src/prompts/tools/rewind.md +3 -2
  126. package/src/prompts/tools/ssh.md +1 -0
  127. package/src/prompts/tools/task.md +4 -0
  128. package/src/sdk.ts +18 -4
  129. package/src/session/agent-session.ts +660 -114
  130. package/src/session/exit-diagnostics.ts +202 -0
  131. package/src/session/session-context.ts +25 -13
  132. package/src/session/session-loader.ts +58 -25
  133. package/src/session/session-manager.ts +0 -1
  134. package/src/session/session-persistence.ts +30 -4
  135. package/src/session/settings-stream-fn.ts +14 -0
  136. package/src/slash-commands/builtin-registry.ts +35 -3
  137. package/src/system-prompt.ts +15 -1
  138. package/src/task/executor.ts +31 -8
  139. package/src/task/index.ts +31 -9
  140. package/src/task/isolation-runner.ts +18 -2
  141. package/src/task/parallel.ts +7 -2
  142. package/src/task/render.test.ts +121 -0
  143. package/src/task/render.ts +48 -2
  144. package/src/task/worktree.ts +12 -13
  145. package/src/task/yield-assembly.ts +8 -5
  146. package/src/thinking.ts +5 -0
  147. package/src/tools/ask.ts +188 -9
  148. package/src/tools/ast-edit.ts +7 -0
  149. package/src/tools/ast-grep.ts +11 -0
  150. package/src/tools/bash.ts +6 -30
  151. package/src/tools/checkpoint.ts +15 -1
  152. package/src/tools/eval-render.ts +15 -0
  153. package/src/tools/fetch.ts +82 -18
  154. package/src/tools/gh.ts +1 -1
  155. package/src/tools/grep.ts +45 -27
  156. package/src/tools/index.ts +3 -1
  157. package/src/tools/irc.ts +24 -9
  158. package/src/tools/output-meta.ts +50 -0
  159. package/src/tools/output-schema-validator.ts +152 -15
  160. package/src/tools/path-utils.ts +55 -10
  161. package/src/tools/read.ts +1 -1
  162. package/src/tools/render-utils.ts +5 -3
  163. package/src/tools/ssh.ts +8 -1
  164. package/src/tools/yield.ts +41 -1
  165. package/src/utils/commit-message-generator.ts +2 -2
  166. package/src/utils/git.ts +271 -29
  167. package/src/utils/thinking-display.ts +13 -0
  168. package/src/web/search/index.ts +9 -28
  169. package/src/web/search/provider.ts +26 -1
  170. package/src/web/search/providers/duckduckgo.ts +1 -1
  171. package/src/web/search/types.ts +5 -1
@@ -13,7 +13,14 @@ import { timingSafeEqual } from "node:crypto";
13
13
  import * as fs from "node:fs/promises";
14
14
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
15
15
  import { logger } from "@oh-my-pi/pi-utils";
16
- import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire";
16
+ import type {
17
+ BusChannel,
18
+ CollabUiRequest,
19
+ CollabUiRequestDraft,
20
+ CollabUiResponseValue,
21
+ AgentEvent as WireAgentEvent,
22
+ SessionEntry as WireSessionEntry,
23
+ } from "@oh-my-pi/pi-wire";
17
24
  import type { InteractiveModeContext } from "../modes/types";
18
25
  import { AgentLifecycleManager } from "../registry/agent-lifecycle";
19
26
  import { type AgentRef, AgentRegistry } from "../registry/agent-registry";
@@ -94,7 +101,8 @@ function isWireSessionEntry(entry: StoredSessionEntry): entry is StoredSessionEn
94
101
  }
95
102
  const CONNECT_TIMEOUT_MS = 15_000;
96
103
  /** Max bytes served per fetch-transcript reply (guest re-requests from `newSize`). */
97
- const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
104
+ export const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
105
+ const TRANSCRIPT_ENTRY_TOO_LARGE_ERROR = `transcript entry exceeds transcript fetch cap (${TRANSCRIPT_READ_CAP} bytes)`;
98
106
  /**
99
107
  * Soft byte cap per `snapshot-chunk` frame. The first MB of a snapshot takes
100
108
  * ~3s through the default relay, so a 512 KB chunk lands well under the
@@ -102,6 +110,13 @@ const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
102
110
  * ship in a chunk of their own.
103
111
  */
104
112
  const SNAPSHOT_CHUNK_BYTES = 512 * 1024;
113
+ /**
114
+ * Outcome of {@link CollabHost.requestGuestUi}. `answered` carries the guest's
115
+ * response (an `undefined` value is a genuine guest cancel); `unavailable`
116
+ * means the collab channel went away (teardown, relay drop) or the request was
117
+ * aborted before any guest answered — callers MUST NOT treat it as a cancel.
118
+ */
119
+ export type CollabGuestUiResult = { kind: "answered"; value: CollabUiResponseValue } | { kind: "unavailable" };
105
120
 
106
121
  export class CollabHost {
107
122
  #ctx: InteractiveModeContext;
@@ -114,6 +129,8 @@ export class CollabHost {
114
129
  #sessionId = "";
115
130
  #unsubscribe?: () => void;
116
131
  #peers = new Map<number, { name: string; canWrite: boolean }>();
132
+ #uiReqSeq = 0;
133
+ #pendingUi = new Map<number, { request: CollabUiRequest; settle(result: CollabGuestUiResult): void }>();
117
134
  #lastStateJson = "";
118
135
  #stateDebounce: Timer | null = null;
119
136
  #streamingInterval: Timer | null = null;
@@ -153,6 +170,43 @@ export class CollabHost {
153
170
  return list;
154
171
  }
155
172
 
173
+ requestGuestUi(request: CollabUiRequestDraft, signal?: AbortSignal): Promise<CollabGuestUiResult> | null {
174
+ if (!this.#socket || !this.#hasWritablePeers()) return null;
175
+ const reqId = ++this.#uiReqSeq;
176
+ const fullRequest: CollabUiRequest = { ...request, reqId };
177
+ const { promise, resolve } = Promise.withResolvers<CollabGuestUiResult>();
178
+ let settled = false;
179
+ const settle = (result: CollabGuestUiResult): void => {
180
+ if (settled) return;
181
+ settled = true;
182
+ signal?.removeEventListener("abort", onAbort);
183
+ this.#pendingUi.delete(reqId);
184
+ this.#sendWritablePeers({ t: "ui-request-end", reqId });
185
+ resolve(result);
186
+ };
187
+ const onAbort = (): void => settle({ kind: "unavailable" });
188
+ if (signal?.aborted) return Promise.resolve({ kind: "unavailable" });
189
+ signal?.addEventListener("abort", onAbort, { once: true });
190
+ this.#pendingUi.set(reqId, { request: fullRequest, settle });
191
+ this.#sendWritablePeers({ t: "ui-request", request: fullRequest });
192
+ return promise;
193
+ }
194
+
195
+ #hasWritablePeers(): boolean {
196
+ for (const peer of this.#peers.values()) {
197
+ if (peer.canWrite) return true;
198
+ }
199
+ return false;
200
+ }
201
+
202
+ #sendWritablePeers(frame: CollabFrame): void {
203
+ const socket = this.#socket;
204
+ if (!socket) return;
205
+ for (const [peerId, peer] of this.#peers) {
206
+ if (peer.canWrite) socket.send(frame, peerId);
207
+ }
208
+ }
209
+
156
210
  async start(relayUrl: string, webUrl = ""): Promise<void> {
157
211
  const rawKey = generateRoomKey();
158
212
  const writeToken = generateWriteToken();
@@ -255,6 +309,8 @@ export class CollabHost {
255
309
  this.#agentsDebounce = null;
256
310
  clearInterval(this.#streamingInterval ?? undefined);
257
311
  this.#streamingInterval = null;
312
+ for (const pending of this.#pendingUi.values()) pending.settle({ kind: "unavailable" });
313
+ this.#pendingUi.clear();
258
314
  this.#peers.clear();
259
315
  this.#socket?.close();
260
316
  this.#socket = null;
@@ -287,6 +343,9 @@ export class CollabHost {
287
343
  case "agent-cmd":
288
344
  this.#handleAgentCmd(frame.cmd, frame.agentId, frame.text, fromPeer);
289
345
  break;
346
+ case "ui-response":
347
+ this.#handleUiResponse(frame.reqId, frame.value, fromPeer);
348
+ break;
290
349
  case "fetch-transcript":
291
350
  void this.#handleFetchTranscript(frame.reqId, frame.agentId, frame.fromByte, fromPeer);
292
351
  break;
@@ -348,6 +407,11 @@ export class CollabHost {
348
407
  fromPeer,
349
408
  );
350
409
  this.#sendSnapshotChunks(entries, fromPeer);
410
+ if (canWrite) {
411
+ for (const pending of this.#pendingUi.values()) {
412
+ socket.send({ t: "ui-request", request: pending.request }, fromPeer);
413
+ }
414
+ }
351
415
  this.#ctx.session.emitNotice(
352
416
  "info",
353
417
  `${cleanName} joined the collab session${canWrite ? "" : " (read-only)"}`,
@@ -392,6 +456,15 @@ export class CollabHost {
392
456
  }
393
457
  }
394
458
 
459
+ #handleUiResponse(reqId: number, value: CollabUiResponseValue, fromPeer: number): void {
460
+ const peer = this.#peers.get(fromPeer);
461
+ if (!peer?.canWrite) {
462
+ this.#rejectReadOnly("responding to ask", fromPeer);
463
+ return;
464
+ }
465
+ this.#pendingUi.get(reqId)?.settle({ kind: "answered", value });
466
+ }
467
+
395
468
  #handlePrompt(text: string, images: ImageContent[] | undefined, fromPeer: number): void {
396
469
  const peer = this.#peers.get(fromPeer);
397
470
  if (!peer?.canWrite) {
@@ -584,7 +657,11 @@ export class CollabHost {
584
657
  if (!reachedEof) {
585
658
  // Trim to the last complete JSONL line so no line or UTF-8 char is split.
586
659
  const lastNewline = slice.lastIndexOf(0x0a);
587
- slice = slice.subarray(0, lastNewline >= 0 ? lastNewline + 1 : 0);
660
+ if (lastNewline < 0) {
661
+ reply("", fromByte, TRANSCRIPT_ENTRY_TOO_LARGE_ERROR);
662
+ return;
663
+ }
664
+ slice = slice.subarray(0, lastNewline + 1);
588
665
  }
589
666
  reply(slice.toString("utf-8"), reachedEof ? stat.size : fromByte + slice.byteLength);
590
667
  } catch (err) {
@@ -10,6 +10,7 @@
10
10
  import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
11
11
  import type {
12
12
  BusChannel,
13
+ CollabUiRequest,
13
14
  GuestFrame,
14
15
  ParsedCollabLink,
15
16
  Participant,
@@ -29,6 +30,10 @@ import type { SessionEntry, SessionHeader } from "../session/session-entries";
29
30
 
30
31
  export type {
31
32
  CollabPromptDetails,
33
+ CollabUiRequest,
34
+ CollabUiRequestDraft,
35
+ CollabUiResponseValue,
36
+ CollabUiSelectItem,
32
37
  ParsedCollabLink,
33
38
  RelayControlMessage,
34
39
  RelayControlToGuest,
@@ -57,7 +62,7 @@ export type CollabSessionState = SessionState & {
57
62
  * that serialize into those shapes.
58
63
  */
59
64
  export type CollabFrame =
60
- // guest -> host (hello/abort/agent-cmd/fetch-transcript are taken verbatim from the wire grammar)
65
+ // guest -> host (hello/abort/agent-cmd/fetch-transcript/ui-response are taken verbatim from the wire grammar)
61
66
  | Exclude<GuestFrame, { t: "prompt" }>
62
67
  | { t: "prompt"; text: string; images?: ImageContent[] }
63
68
  // host -> guest
@@ -93,7 +98,9 @@ export type CollabFrame =
93
98
  | { t: "bus"; channel: BusChannel; data: unknown }
94
99
  /** Full agent-registry snapshot (debounced on registry change). */
95
100
  | { t: "agents"; agents: AgentSnapshot[] }
96
- /** Targeted reply to fetch-transcript; `text` is decoded JSONL from `fromByte`, `newSize` the next offset base. */
101
+ | { t: "ui-request"; request: CollabUiRequest }
102
+ | { t: "ui-request-end"; reqId: number }
103
+ /** Targeted reply to fetch-transcript; `error` marks a terminal read failure that guests must surface without hot retrying. */
97
104
  | { t: "transcript"; reqId: number; text: string; newSize: number; error?: string }
98
105
  | { t: "bye"; reason: string }
99
106
  | { t: "error"; message: string };
@@ -11,6 +11,7 @@ import {
11
11
  import { MODEL_ROLE_IDS } from "../config/model-roles";
12
12
  import type { Settings } from "../config/settings";
13
13
  import MODEL_PRIO from "../priority.json" with { type: "json" };
14
+ import { concreteThinkingLevel } from "../thinking";
14
15
 
15
16
  export interface ResolvedCommitModel {
16
17
  model: Model<Api>;
@@ -20,6 +21,11 @@ export interface ResolvedCommitModel {
20
21
  * central force-refresh + account-rotation policy.
21
22
  */
22
23
  apiKey: ApiKey;
24
+ /**
25
+ * Commit-time inference is stateless: session-level auto classification
26
+ * isn't available, so an explicit `:auto` selector collapses to "no
27
+ * override" and the model's own default level fills in.
28
+ */
23
29
  thinkingLevel?: ThinkingLevel;
24
30
  }
25
31
 
@@ -49,7 +55,7 @@ export async function resolvePrimaryModel(
49
55
  return {
50
56
  model,
51
57
  apiKey: modelRegistry.resolver(model),
52
- thinkingLevel: resolved?.thinkingLevel,
58
+ thinkingLevel: concreteThinkingLevel(resolved?.thinkingLevel),
53
59
  };
54
60
  }
55
61
 
@@ -67,7 +73,7 @@ export async function resolveSmolModel(
67
73
  return {
68
74
  model: resolvedSmol.model,
69
75
  apiKey: modelRegistry.resolver(resolvedSmol.model),
70
- thinkingLevel: resolvedSmol.thinkingLevel,
76
+ thinkingLevel: concreteThinkingLevel(resolvedSmol.thinkingLevel),
71
77
  };
72
78
  }
73
79
  }
@@ -65,7 +65,9 @@ declare module "@oh-my-pi/pi-tui" {
65
65
  * Resolve default image-paste shortcuts for the current terminal platform.
66
66
  */
67
67
  export function getDefaultPasteImageKeys(platform: NodeJS.Platform = process.platform): KeyId[] {
68
- return platform === "win32" ? ["ctrl+v", "alt+v"] : ["ctrl+v"];
68
+ if (platform === "win32") return ["ctrl+v", "alt+v"];
69
+ if (platform === "darwin") return ["ctrl+v", "super+v"];
70
+ return ["ctrl+v"];
69
71
  }
70
72
 
71
73
  /**
@@ -158,6 +158,15 @@ type LlamaCppDiscoveredModelRuntimeMetadata = {
158
158
  type LlamaCppModelListEntry = {
159
159
  id: string;
160
160
  runtimeContextWindow?: number;
161
+ /**
162
+ * `--ctx-size` extracted from the entry's `status.args` (rendered CLI arg
163
+ * vector) or `status.preset` INI. Populated for llama-server router-mode
164
+ * presets so unloaded models surface the user's configured window instead
165
+ * of falling through to the 128K default — the router-level `/props`
166
+ * reports a dummy `n_ctx: 0` and `meta.n_ctx` is only merged in after a
167
+ * child instance loads (issue #4190).
168
+ */
169
+ configuredContextWindow?: number;
161
170
  trainingContextWindow?: number;
162
171
  };
163
172
 
@@ -274,10 +283,60 @@ function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
274
283
  if (!isRecord(item) || typeof item.id !== "string" || !item.id) {
275
284
  return [];
276
285
  }
277
- return [{ id: item.id, ...extractLlamaCppModelContextWindows(item) }];
286
+ return [
287
+ {
288
+ id: item.id,
289
+ ...extractLlamaCppModelContextWindows(item),
290
+ configuredContextWindow: extractLlamaCppConfiguredContextWindow(item),
291
+ },
292
+ ];
278
293
  });
279
294
  }
280
295
 
296
+ // llama-server's `to_args()` renders the long form `--ctx-size` (never `-c`),
297
+ // but tolerate the short form and the embedded `--flag=value` shape so a
298
+ // hand-rolled forwarder cannot silently downgrade the discovered window.
299
+ const LLAMA_CPP_CTX_SIZE_FLAGS = new Set(["--ctx-size", "-c"]);
300
+
301
+ function extractLlamaCppCtxSizeFromArgs(value: unknown): number | undefined {
302
+ if (!Array.isArray(value)) {
303
+ return undefined;
304
+ }
305
+ for (let i = 0; i < value.length; i++) {
306
+ const raw = value[i];
307
+ if (typeof raw !== "string") continue;
308
+ const eq = raw.indexOf("=");
309
+ const flag = eq >= 0 ? raw.slice(0, eq) : raw;
310
+ if (!LLAMA_CPP_CTX_SIZE_FLAGS.has(flag)) continue;
311
+ const rawValue = eq >= 0 ? raw.slice(eq + 1) : value[i + 1];
312
+ const parsed = toPositiveNumberOrUndefined(rawValue);
313
+ if (parsed !== undefined) return parsed;
314
+ }
315
+ return undefined;
316
+ }
317
+
318
+ // `common_preset::to_ini()` emits one option per line as `<long-arg-without-dashes> = <value>`,
319
+ // so `ctx-size = 8192` is the exact wire form (issue #4190).
320
+ function extractLlamaCppCtxSizeFromIni(value: unknown): number | undefined {
321
+ if (typeof value !== "string") {
322
+ return undefined;
323
+ }
324
+ const match = value.match(/(?:^|\n)\s*ctx-size\s*=\s*(-?\d+)\s*(?:$|\n)/);
325
+ return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
326
+ }
327
+
328
+ function extractLlamaCppConfiguredContextWindow(item: Record<string, unknown>): number | undefined {
329
+ const status = item.status;
330
+ if (!isRecord(status)) {
331
+ return undefined;
332
+ }
333
+ const fromArgs = extractLlamaCppCtxSizeFromArgs(status.args);
334
+ if (fromArgs !== undefined) {
335
+ return fromArgs;
336
+ }
337
+ return extractLlamaCppCtxSizeFromIni(status.preset);
338
+ }
339
+
281
340
  function extractLlamaCppInputCapabilities(payload: Record<string, unknown>): ("text" | "image")[] | undefined {
282
341
  const modalities = payload.modalities;
283
342
  if (!isRecord(modalities)) {
@@ -473,6 +532,7 @@ export async function discoverLlamaCppModels(
473
532
  if (!id) continue;
474
533
  const contextWindow =
475
534
  item.runtimeContextWindow ??
535
+ item.configuredContextWindow ??
476
536
  serverMetadata?.contextWindow ??
477
537
  item.trainingContextWindow ??
478
538
  DISCOVERY_DEFAULT_CONTEXT_WINDOW;
@@ -529,7 +589,11 @@ export async function discoverLlamaCppModelRuntimeMetadata(
529
589
  if (!entry) {
530
590
  return undefined;
531
591
  }
532
- const contextWindow = entry.runtimeContextWindow ?? serverMetadata?.contextWindow ?? entry.trainingContextWindow;
592
+ const contextWindow =
593
+ entry.runtimeContextWindow ??
594
+ entry.configuredContextWindow ??
595
+ serverMetadata?.contextWindow ??
596
+ entry.trainingContextWindow;
533
597
  if (contextWindow === undefined) {
534
598
  return undefined;
535
599
  }
@@ -52,7 +52,7 @@ import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
52
52
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
53
53
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
54
54
  import { getBundledModelReferenceIndex, resolveModelReference } from "@oh-my-pi/pi-catalog/identity";
55
- import { isBunTestRuntime, isRecord, logger } from "@oh-my-pi/pi-utils";
55
+ import { isBunTestRuntime, isRecord, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
56
56
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
57
57
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
58
58
  import { type ApiKeyResolverModel, type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
@@ -646,6 +646,7 @@ function normalizeSuppressedSelector(
646
646
  if (!trimmed) return trimmed;
647
647
  const parsed = parseModelString(trimmed, {
648
648
  allowMaxAlias: true,
649
+ allowAutoAlias: true,
649
650
  isLiteralModelId: (provider, id) => hasLiveModel?.(provider, id) === true,
650
651
  });
651
652
  if (!parsed) return trimmed;
@@ -762,7 +763,7 @@ export class ModelRegistry {
762
763
  options?.fetch ??
763
764
  (isBunTestRuntime()
764
765
  ? () => Promise.reject(new Error("network disabled in model-registry runtime test"))
765
- : fetch);
766
+ : wrapFetchForExtraCa(fetch));
766
767
  this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
767
768
  this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
768
769
  // Set up fallback resolver for custom provider API keys
@@ -1368,7 +1369,10 @@ export class ModelRegistry {
1368
1369
  return `${providerConfig.provider}:openai-models-list-context-v2`;
1369
1370
  }
1370
1371
  if (providerConfig.discovery.type === "litellm") {
1371
- return `${providerConfig.provider}:litellm-rich-v1`;
1372
+ // rich-v2 invalidates rows cached before reseller usage-suffix stripping
1373
+ // (stale display names like `MiniMax-M3 (3x usage)`); keep in lockstep
1374
+ // with the catalog package's `litellm:rich-vN` namespace.
1375
+ return `${providerConfig.provider}:litellm-rich-v2`;
1372
1376
  }
1373
1377
  return providerConfig.provider;
1374
1378
  }
@@ -29,7 +29,13 @@ import { fuzzyMatch } from "@oh-my-pi/pi-tui";
29
29
  import { logger } from "@oh-my-pi/pi-utils";
30
30
  import chalk from "chalk";
31
31
  import MODEL_PRIO from "../priority.json" with { type: "json" };
32
- import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
32
+ import {
33
+ AUTO_THINKING,
34
+ type ConfiguredThinkingLevel,
35
+ concreteThinkingLevel,
36
+ parseThinkingLevel,
37
+ resolveThinkingLevelForModel,
38
+ } from "../thinking";
33
39
  import { isAuthenticated, kNoAuth, type ModelRegistry } from "./model-registry";
34
40
  import { MODEL_ROLE_IDS, type ModelRole } from "./model-roles";
35
41
  import type { Settings } from "./settings";
@@ -74,24 +80,32 @@ export interface ScopedModel {
74
80
 
75
81
  interface ThinkingSuffixOptions {
76
82
  allowMaxAlias?: boolean;
83
+ allowAutoAlias?: boolean;
77
84
  }
78
85
 
79
86
  interface ModelStringParseOptions extends ThinkingSuffixOptions {
80
87
  isLiteralModelId?: (provider: string, id: string) => boolean;
81
88
  }
82
- const MAX_THINKING_SUFFIX_OPTIONS: ThinkingSuffixOptions = { allowMaxAlias: true };
89
+ // Alias-suffix recognition for the model-pattern parser: `:max` maps to xhigh
90
+ // and `:auto` maps to the auto sentinel. Both are gated behind the alias flags
91
+ // (and the literal-id / exact-match guards on the callers) so a real model id
92
+ // ending in `:max` / `:auto` isn't silently reinterpreted as a thinking suffix.
93
+ const MAX_THINKING_SUFFIX_OPTIONS: ThinkingSuffixOptions = { allowMaxAlias: true, allowAutoAlias: true };
83
94
 
84
- function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ThinkingLevel | undefined {
95
+ function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ConfiguredThinkingLevel | undefined {
85
96
  const level = parseThinkingLevel(value);
86
97
  if (level !== undefined) return level;
87
- return options?.allowMaxAlias === true && value === "max" ? ThinkingLevel.XHigh : undefined;
98
+ if (options?.allowMaxAlias === true && value === "max") return ThinkingLevel.XHigh;
99
+ if (options?.allowAutoAlias === true && value === AUTO_THINKING) return AUTO_THINKING;
100
+ return undefined;
88
101
  }
89
102
 
90
103
  /**
91
104
  * Split a trailing `:<level>` thinking selector off a model pattern.
92
105
  *
93
- * `level` is set only when the suffix parses as a valid thinking level, in
94
- * which case `base` has the suffix stripped; otherwise `base` is the input.
106
+ * `level` is set when the suffix parses as a concrete thinking level (or, when
107
+ * the caller opts in via `allowMaxAlias`/`allowAutoAlias`, the `:max` / `:auto`
108
+ * aliases); `base` then has the suffix stripped. Otherwise `base` is the input.
95
109
  * `minColonIndex` requires the colon to appear strictly after that index —
96
110
  * role-alias callers pass `PREFIX_MODEL_ROLE.length` so the base is at least
97
111
  * as long as the `pi/` prefix.
@@ -100,7 +114,7 @@ function splitThinkingSuffix(
100
114
  pattern: string,
101
115
  minColonIndex = -1,
102
116
  options?: ThinkingSuffixOptions,
103
- ): { base: string; level?: ThinkingLevel } {
117
+ ): { base: string; level?: ConfiguredThinkingLevel } {
104
118
  const colonIdx = pattern.lastIndexOf(":");
105
119
  if (colonIdx <= minColonIndex) return { base: pattern };
106
120
  const level = parseThinkingSuffix(pattern.slice(colonIdx + 1), options);
@@ -119,12 +133,17 @@ function resolveGlobScopePattern(
119
133
  pattern: string,
120
134
  availableModels: readonly Model<Api>[],
121
135
  ): { models: Model<Api>[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
136
+ // Glob scopes describe which models are enabled, not per-role thinking.
137
+ // Coerce the `auto` sentinel to a concrete-only view so scope callers stay
138
+ // typed on `ThinkingLevel` and `enabledModels: [\"openai/*:auto\"]` doesn't
139
+ // pin a stray per-model level.
122
140
  const strictSuffix = splitThinkingSuffix(pattern);
123
141
  if (strictSuffix.level !== undefined) {
142
+ const thinkingLevel = concreteThinkingLevel(strictSuffix.level);
124
143
  return {
125
144
  models: matchingGlobModels(strictSuffix.base, availableModels),
126
- thinkingLevel: strictSuffix.level,
127
- explicitThinkingLevel: true,
145
+ thinkingLevel,
146
+ explicitThinkingLevel: thinkingLevel !== undefined,
128
147
  };
129
148
  }
130
149
 
@@ -134,10 +153,11 @@ function resolveGlobScopePattern(
134
153
  if (literalMatches.length > 0) {
135
154
  return { models: literalMatches, thinkingLevel: undefined, explicitThinkingLevel: false };
136
155
  }
156
+ const thinkingLevel = concreteThinkingLevel(maxSuffix.level);
137
157
  return {
138
158
  models: matchingGlobModels(maxSuffix.base, availableModels),
139
- thinkingLevel: maxSuffix.level,
140
- explicitThinkingLevel: true,
159
+ thinkingLevel,
160
+ explicitThinkingLevel: thinkingLevel !== undefined,
141
161
  };
142
162
  }
143
163
 
@@ -155,7 +175,7 @@ function resolveGlobScopePattern(
155
175
  export function parseModelString(
156
176
  modelStr: string,
157
177
  options?: ModelStringParseOptions,
158
- ): { provider: string; id: string; thinkingLevel?: ThinkingLevel } | undefined {
178
+ ): { provider: string; id: string; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
159
179
  const slashIdx = modelStr.indexOf("/");
160
180
  if (slashIdx <= 0) return undefined;
161
181
  const id = modelStr.slice(slashIdx + 1);
@@ -208,7 +228,7 @@ export function formatModelStringWithRouting(model: Model<Api>): string {
208
228
  return upstream ? `${selector}@${upstream}` : selector;
209
229
  }
210
230
 
211
- export function formatModelSelectorValue(selector: string, thinkingLevel: ThinkingLevel | undefined): string {
231
+ export function formatModelSelectorValue(selector: string, thinkingLevel: ConfiguredThinkingLevel | undefined): string {
212
232
  return thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit ? `${selector}:${thinkingLevel}` : selector;
213
233
  }
214
234
 
@@ -711,7 +731,7 @@ function matchModel(
711
731
  export interface ParsedModelResult {
712
732
  model: Model<Api> | undefined;
713
733
  /** Thinking level if explicitly specified in pattern, undefined otherwise */
714
- thinkingLevel?: ThinkingLevel;
734
+ thinkingLevel?: ConfiguredThinkingLevel;
715
735
  /** Upstream provider slug from an `@upstream` routing selector, if present. */
716
736
  upstream?: string;
717
737
  warning: string | undefined;
@@ -989,7 +1009,7 @@ export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOp
989
1009
  */
990
1010
  export interface ResolvedModelRoleValue {
991
1011
  model: Model<Api> | undefined;
992
- thinkingLevel?: ThinkingLevel;
1012
+ thinkingLevel?: ConfiguredThinkingLevel;
993
1013
  explicitThinkingLevel: boolean;
994
1014
  warning: string | undefined;
995
1015
  }
@@ -1021,7 +1041,9 @@ export function resolveModelRoleValue(
1021
1041
  return {
1022
1042
  model: resolved.model,
1023
1043
  thinkingLevel: resolved.explicitThinkingLevel
1024
- ? (resolveThinkingLevelForModel(resolved.model, resolved.thinkingLevel) ?? resolved.thinkingLevel)
1044
+ ? resolved.thinkingLevel === AUTO_THINKING
1045
+ ? AUTO_THINKING
1046
+ : (resolveThinkingLevelForModel(resolved.model, resolved.thinkingLevel) ?? resolved.thinkingLevel)
1025
1047
  : resolved.thinkingLevel,
1026
1048
  explicitThinkingLevel: resolved.explicitThinkingLevel,
1027
1049
  warning: resolved.warning,
@@ -1048,7 +1070,7 @@ export function extractExplicitThinkingSelector(
1048
1070
  value: string | undefined,
1049
1071
  settings?: Settings,
1050
1072
  options?: ExplicitThinkingSelectorOptions,
1051
- ): ThinkingLevel | undefined {
1073
+ ): ConfiguredThinkingLevel | undefined {
1052
1074
  if (!value) return undefined;
1053
1075
  const normalized = value.trim();
1054
1076
  if (!normalized || normalized === DEFAULT_MODEL_ROLE) return undefined;
@@ -1127,7 +1149,7 @@ export function resolveModelOverride(
1127
1149
  modelPatterns: string[],
1128
1150
  modelRegistry: ModelLookupRegistry,
1129
1151
  settings?: Settings,
1130
- ): { model?: Model<Api>; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
1152
+ ): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean } {
1131
1153
  if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
1132
1154
  const availableModels = modelRegistry.getAvailable();
1133
1155
  const matchPreferences = getModelMatchPreferences(settings);
@@ -1171,7 +1193,7 @@ export async function resolveModelOverrideWithAuthFallback(
1171
1193
  settings?: Settings,
1172
1194
  ): Promise<{
1173
1195
  model?: Model<Api>;
1174
- thinkingLevel?: ThinkingLevel;
1196
+ thinkingLevel?: ConfiguredThinkingLevel;
1175
1197
  explicitThinkingLevel: boolean;
1176
1198
  authFallbackUsed: boolean;
1177
1199
  }> {
@@ -1207,7 +1229,7 @@ export function resolveRoleSelection(
1207
1229
  roles: readonly string[],
1208
1230
  settings: Settings,
1209
1231
  availableModels: Model<Api>[],
1210
- ): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
1232
+ ): { model: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
1211
1233
  const matchPreferences = getModelMatchPreferences(settings);
1212
1234
  for (const role of roles) {
1213
1235
  const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
@@ -1232,7 +1254,7 @@ export function resolveRoleSelection(
1232
1254
  export function resolveAdvisorRoleSelection(
1233
1255
  settings: Settings,
1234
1256
  availableModels: Model<Api>[],
1235
- ): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
1257
+ ): { model: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
1236
1258
  const resolved = resolveModelRoleValue(`${PREFIX_MODEL_ROLE}advisor`, availableModels, {
1237
1259
  settings,
1238
1260
  matchPreferences: getModelMatchPreferences(settings),
@@ -1307,7 +1329,13 @@ export async function resolveModelScope(
1307
1329
  continue;
1308
1330
  }
1309
1331
 
1310
- addScopedModel(model, thinkingLevel, explicitThinkingLevel);
1332
+ // Scoped models (Ctrl+P cycling) carry concrete per-model overrides;
1333
+ // `auto` lives on the session, so drop the sentinel here.
1334
+ if (thinkingLevel === AUTO_THINKING) {
1335
+ addScopedModel(model, undefined, false);
1336
+ } else {
1337
+ addScopedModel(model, thinkingLevel, explicitThinkingLevel);
1338
+ }
1311
1339
  }
1312
1340
 
1313
1341
  return scopedModels;
@@ -1393,7 +1421,7 @@ export function filterAvailableModelsByEnabledPatterns(
1393
1421
  export interface ResolveCliModelResult {
1394
1422
  model: Model<Api> | undefined;
1395
1423
  selector?: string;
1396
- thinkingLevel?: ThinkingLevel;
1424
+ thinkingLevel?: ConfiguredThinkingLevel;
1397
1425
  warning: string | undefined;
1398
1426
  error: string | undefined;
1399
1427
  }
@@ -127,6 +127,9 @@ const RemoteCompactionSchema = type({
127
127
  "api?": ApiSchema,
128
128
  "endpoint?": "string",
129
129
  "model?": "string",
130
+ "v2StreamingEnabled?": "boolean",
131
+ "v2Endpoint?": "string",
132
+ "streamingEndpoint?": "string",
130
133
  }).narrow((value, ctx) => {
131
134
  if (value.endpoint !== undefined && typeof value.endpoint === "string" && value.endpoint.length === 0) {
132
135
  return ctx.mustBe("remoteCompaction.endpoint a non-empty string");
@@ -134,6 +137,16 @@ const RemoteCompactionSchema = type({
134
137
  if (value.model !== undefined && typeof value.model === "string" && value.model.length === 0) {
135
138
  return ctx.mustBe("remoteCompaction.model a non-empty string");
136
139
  }
140
+ if (value.v2Endpoint !== undefined && typeof value.v2Endpoint === "string" && value.v2Endpoint.length === 0) {
141
+ return ctx.mustBe("remoteCompaction.v2Endpoint a non-empty string");
142
+ }
143
+ if (
144
+ value.streamingEndpoint !== undefined &&
145
+ typeof value.streamingEndpoint === "string" &&
146
+ value.streamingEndpoint.length === 0
147
+ ) {
148
+ return ctx.mustBe("remoteCompaction.streamingEndpoint a non-empty string");
149
+ }
137
150
  return true;
138
151
  });
139
152