@oh-my-pi/pi-coding-agent 16.2.4 → 16.2.6

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 (68) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli.js +3211 -3231
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/edit/index.d.ts +1 -0
  8. package/dist/types/edit/renderer.d.ts +4 -0
  9. package/dist/types/edit/snapshot-details.d.ts +33 -0
  10. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  11. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  12. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  13. package/dist/types/modes/theme/theme.d.ts +2 -1
  14. package/dist/types/session/agent-session.d.ts +11 -0
  15. package/dist/types/session/session-manager.d.ts +3 -4
  16. package/dist/types/task/provider-concurrency.d.ts +40 -0
  17. package/dist/types/utils/git.d.ts +11 -0
  18. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  19. package/dist/types/web/search/types.d.ts +1 -1
  20. package/package.json +12 -12
  21. package/src/cli/args.ts +32 -1
  22. package/src/cli/bench-cli.ts +89 -21
  23. package/src/cli/web-search-cli.ts +6 -1
  24. package/src/cli/worktree-cli.ts +8 -5
  25. package/src/commands/bench.ts +10 -1
  26. package/src/config/mcp-schema.json +1 -1
  27. package/src/config/model-discovery.ts +98 -20
  28. package/src/config/model-registry.ts +13 -6
  29. package/src/config/settings-schema.ts +5 -2
  30. package/src/edit/hashline/execute.ts +15 -9
  31. package/src/edit/index.ts +19 -6
  32. package/src/edit/modes/patch.ts +3 -2
  33. package/src/edit/modes/replace.ts +3 -2
  34. package/src/edit/renderer.ts +4 -0
  35. package/src/edit/snapshot-details.ts +77 -0
  36. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  37. package/src/internal-urls/docs-index.generated.txt +1 -1
  38. package/src/mcp/oauth-flow.ts +10 -8
  39. package/src/mcp/transports/stdio.ts +9 -17
  40. package/src/modes/components/status-line/component.ts +29 -2
  41. package/src/modes/components/status-line/segments.ts +22 -8
  42. package/src/modes/components/status-line/types.ts +7 -0
  43. package/src/modes/controllers/event-controller.ts +17 -8
  44. package/src/modes/controllers/input-controller.ts +1 -1
  45. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  46. package/src/modes/theme/theme.ts +6 -0
  47. package/src/prompts/bench.md +4 -10
  48. package/src/prompts/tools/irc.md +19 -29
  49. package/src/prompts/tools/job.md +8 -14
  50. package/src/prompts/tools/lsp.md +19 -30
  51. package/src/prompts/tools/task.md +42 -62
  52. package/src/sdk.ts +14 -5
  53. package/src/session/agent-session.ts +107 -13
  54. package/src/session/session-listing.ts +9 -8
  55. package/src/session/session-loader.ts +98 -3
  56. package/src/session/session-manager.ts +34 -4
  57. package/src/subprocess/worker-client.ts +12 -4
  58. package/src/task/executor.ts +4 -62
  59. package/src/task/provider-concurrency.ts +100 -0
  60. package/src/task/worktree.ts +13 -4
  61. package/src/task/yield-assembly.ts +27 -39
  62. package/src/tools/path-utils.ts +4 -2
  63. package/src/tools/read.ts +11 -1
  64. package/src/utils/git.ts +13 -0
  65. package/src/web/search/index.ts +14 -8
  66. package/src/web/search/providers/duckduckgo.ts +136 -78
  67. package/src/web/search/providers/gemini.ts +268 -185
  68. package/src/web/search/types.ts +1 -1
@@ -8,6 +8,8 @@ import type {
8
8
  Context,
9
9
  Effort,
10
10
  Model,
11
+ ProviderSessionState,
12
+ ServiceTier,
11
13
  SimpleStreamOptions,
12
14
  } from "@oh-my-pi/pi-ai";
13
15
  import { streamSimple } from "@oh-my-pi/pi-ai";
@@ -23,12 +25,14 @@ import {
23
25
  getModelMatchPreferences,
24
26
  resolveCliModel,
25
27
  } from "../config/model-resolver";
28
+ import { resolveServiceTierSetting } from "../config/service-tier";
26
29
  import { Settings } from "../config/settings";
27
30
  import benchPrompt from "../prompts/bench.md" with { type: "text" };
28
31
  import { discoverAuthStorage, loadCliExtensionProviders } from "../sdk";
29
32
  import { resolveThinkingLevelForModel, shouldDisableReasoning, toReasoningEffort } from "../thinking";
30
33
 
31
- const DEFAULT_RUNS = 1;
34
+ const DEFAULT_RUNS = 10;
35
+ const DEFAULT_PAR = 4;
32
36
  const DEFAULT_MAX_TOKENS = 512;
33
37
  const ERROR_WIDTH = 110;
34
38
  const BENCH_PROMPT = benchPrompt.trim();
@@ -39,7 +43,10 @@ export interface BenchCommandArgs {
39
43
  runs?: number;
40
44
  maxTokens?: number;
41
45
  prompt?: string;
46
+ /** Service-tier setting value (`none` omits); overrides the configured `serviceTier` setting. */
47
+ serviceTier?: string;
42
48
  json?: boolean;
49
+ par?: number;
43
50
  };
44
51
  }
45
52
 
@@ -99,6 +106,8 @@ export interface BenchSummary {
99
106
  maxTokens: number;
100
107
  models: BenchModelReport[];
101
108
  failures: number;
109
+ /** Requested service tier passed to every request; absent when none was requested. Scoped tiers (`openai-only`/`claude-only`) may be dropped per-provider downstream. */
110
+ serviceTier?: ServiceTier;
102
111
  }
103
112
 
104
113
  type BenchStreamSimple = (
@@ -131,6 +140,13 @@ function normalizePositiveInteger(name: string, value: number | undefined, fallb
131
140
  return value;
132
141
  }
133
142
 
143
+ function closeProviderSessionStates(providerSessionState: Map<string, ProviderSessionState>): void {
144
+ for (const state of providerSessionState.values()) {
145
+ state.close();
146
+ }
147
+ providerSessionState.clear();
148
+ }
149
+
134
150
  function isFirstTokenEvent(event: AssistantMessageEvent): boolean {
135
151
  switch (event.type) {
136
152
  case "text_delta":
@@ -188,6 +204,8 @@ interface BenchRequestOptions {
188
204
  reasoning?: Effort;
189
205
  /** Only set for an explicit `:off` suffix — some endpoints reject disablement. */
190
206
  disableReasoning?: boolean;
207
+ /** Requested service tier passed to `streamSimple`; absent omits the option. The provider layer applies scope/support gating before it reaches the wire. */
208
+ serviceTier?: ServiceTier;
191
209
  }
192
210
 
193
211
  async function runBenchRequest(
@@ -198,6 +216,7 @@ async function runBenchRequest(
198
216
  ): Promise<BenchRunResult> {
199
217
  const startedAt = now();
200
218
  let firstTokenAt: number | undefined;
219
+ const providerSessionState = new Map<string, ProviderSessionState>();
201
220
  try {
202
221
  const context: Context = {
203
222
  // Codex's Responses endpoint 400s with "Instructions are required" when no
@@ -214,6 +233,9 @@ async function runBenchRequest(
214
233
  : options.maxTokens,
215
234
  reasoning: options.reasoning,
216
235
  disableReasoning: options.disableReasoning,
236
+ serviceTier: options.serviceTier,
237
+ providerSessionState,
238
+ preferWebsockets: true,
217
239
  // pi-ai opts every OpenRouter request into response caching (1h TTL).
218
240
  // Bench sends a byte-identical request each run, so within the TTL
219
241
  // OpenRouter replays the cached generation with zeroed usage — the run
@@ -270,6 +292,8 @@ async function runBenchRequest(
270
292
  };
271
293
  } catch (error) {
272
294
  return { ok: false, error: getErrorMessage(error) };
295
+ } finally {
296
+ closeProviderSessionStates(providerSessionState);
273
297
  }
274
298
  }
275
299
 
@@ -469,10 +493,11 @@ function resolveBenchModels(
469
493
  }
470
494
  return resolved;
471
495
  }
472
-
473
496
  export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDependencies = {}): Promise<BenchSummary> {
474
497
  const runs = normalizePositiveInteger("runs", command.flags.runs, DEFAULT_RUNS);
475
498
  const maxTokens = normalizePositiveInteger("max-tokens", command.flags.maxTokens, DEFAULT_MAX_TOKENS);
499
+ const par =
500
+ command.flags.par !== undefined ? normalizePositiveInteger("par", command.flags.par, DEFAULT_PAR) : DEFAULT_PAR;
476
501
  const prompt = command.flags.prompt?.trim() || BENCH_PROMPT;
477
502
  const json = command.flags.json === true;
478
503
  const randomSessionId = deps.randomSessionId ?? (() => Bun.randomUUIDv7());
@@ -493,6 +518,12 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
493
518
  const runtime = await (deps.createRuntime ?? createDefaultRuntime)();
494
519
  try {
495
520
  const targets = resolveBenchModels(command.models, runtime.modelRegistry, runtime.settings, writeStderr);
521
+ // Explicit `--service-tier` wins; otherwise fall back to the configured
522
+ // `serviceTier` setting (`none`/unset omits the wire field). Scope-aware
523
+ // gating to the model's provider happens downstream in the provider layer.
524
+ const serviceTierValue = command.flags.serviceTier ?? runtime.settings?.get("serviceTier");
525
+ const serviceTier = serviceTierValue ? resolveServiceTierSetting(serviceTierValue, undefined) : undefined;
526
+ if (!json && serviceTier) writeStdout(`${chalk.dim(`service tier: ${serviceTier}`)}\n`);
496
527
  const reports: BenchModelReport[] = [];
497
528
  for (const { selector, model, thinking } of targets) {
498
529
  if (!json) {
@@ -501,21 +532,29 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
501
532
  writeStdout(`${chalk.bold(resolvedModel)}${resolvedNote}\n`);
502
533
  }
503
534
  const results: BenchRunResult[] = [];
504
- for (let index = 0; index < runs; index++) {
505
- const sessionId = randomSessionId();
506
- const initialKey = await runtime.modelRegistry.getApiKey(model, sessionId);
507
- if (!initialKey) {
508
- const failure: BenchRunFailure = {
509
- ok: false,
510
- error: `No credentials for provider "${model.provider}". Run \`omp\` and use /login, or set the provider API key.`,
511
- };
512
- results.push(failure);
513
- if (!json) writeStdout(`${formatRunLine(failure, index, runs)}\n`);
514
- break; // remaining runs would fail identically
515
- }
516
- if (!json && interactive) {
517
- writeStdout(chalk.dim(` … run ${index + 1}/${runs} streaming`));
518
- }
535
+
536
+ // Preflight check: let's verify credentials before starting any runs.
537
+ // This matches the old sequential break behavior exactly and avoids launching/printing
538
+ // multiple failures.
539
+ const testSessionId = randomSessionId();
540
+ const preflightKey = await runtime.modelRegistry.getApiKey(model, testSessionId);
541
+ if (!preflightKey) {
542
+ const failure: BenchRunFailure = {
543
+ ok: false,
544
+ error: `No credentials for provider "${model.provider}". Run \`omp\` and use /login, or set the provider API key.`,
545
+ };
546
+ results.push(failure);
547
+ if (!json) writeStdout(`${formatRunLine(failure, 0, runs)}\n`);
548
+ reports.push(buildModelReport(selector, model, thinking, results));
549
+ continue;
550
+ }
551
+
552
+ // We will launch up to `par` workers/requests concurrently.
553
+ // To keep output clean, non-JSON output emits entries in correct index order.
554
+ let nextToPrint = 0;
555
+
556
+ const runWorker = async (index: number) => {
557
+ const sessionId = index === 0 ? testSessionId : randomSessionId();
519
558
  const result = await runBenchRequest(
520
559
  model,
521
560
  {
@@ -525,20 +564,49 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
525
564
  maxTokens,
526
565
  reasoning: toReasoningEffort(thinking),
527
566
  disableReasoning: shouldDisableReasoning(thinking) ? true : undefined,
567
+ serviceTier,
528
568
  },
529
569
  streamFn,
530
570
  now,
531
571
  );
532
- results.push(result);
572
+ results[index] = result;
573
+ };
574
+
575
+ // Concurrency-limited running pool
576
+ const queue = Array.from({ length: runs }, (_, i) => i);
577
+ const activeWorkers: Promise<void>[] = [];
578
+
579
+ const processNext = async (): Promise<void> => {
580
+ if (queue.length === 0) return;
581
+ const index = queue.shift()!;
582
+
583
+ // Pre-print a status update if requested and interactive
584
+ if (!json && interactive) {
585
+ writeStdout(chalk.dim(` … run ${index + 1}/${runs} streaming\n`));
586
+ }
587
+
588
+ await runWorker(index);
589
+
590
+ // Attempt to print completed results that are in-order
533
591
  if (!json) {
534
- if (interactive) writeStdout("\r\x1b[2K");
535
- writeStdout(`${formatRunLine(result, index, runs)}\n`);
592
+ while (nextToPrint < runs && results[nextToPrint] !== undefined) {
593
+ const res = results[nextToPrint];
594
+ writeStdout(`${formatRunLine(res, nextToPrint, runs)}\n`);
595
+ nextToPrint++;
596
+ }
536
597
  }
598
+
599
+ await processNext();
600
+ };
601
+
602
+ for (let w = 0; w < Math.min(par, runs); w++) {
603
+ activeWorkers.push(processNext());
537
604
  }
605
+ await Promise.all(activeWorkers);
538
606
  reports.push(buildModelReport(selector, model, thinking, results));
539
607
  }
540
608
  const failures = reports.reduce((sum, report) => sum + report.results.filter(result => !result.ok).length, 0);
541
- const summary: BenchSummary = { runs, maxTokens, models: reports, failures };
609
+ const summary: BenchSummary = { runs, maxTokens, models: reports, failures, serviceTier };
542
610
  if (json) {
543
611
  writeStdout(`${JSON.stringify(summary, null, 2)}\n`);
544
612
  } else if (reports.length > 1 || runs > 1) {
@@ -4,8 +4,10 @@
4
4
  * Handles `omp q`/`omp web-search` subcommands for testing web search providers.
5
5
  */
6
6
 
7
- import { APP_NAME } from "@oh-my-pi/pi-utils";
7
+ import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils";
8
8
  import chalk from "chalk";
9
+ import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
10
+ import { Settings } from "../config/settings";
9
11
  import { initTheme, theme } from "../modes/theme/theme";
10
12
  import { runSearchQuery, type SearchQueryParams } from "../web/search/index";
11
13
  import { SEARCH_PROVIDER_ORDER } from "../web/search/provider";
@@ -85,6 +87,9 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
85
87
  process.exit(1);
86
88
  }
87
89
 
90
+ const settings = await Settings.init({ cwd: getProjectDir() });
91
+ applyProviderGlobalsFromSettings(settings);
92
+
88
93
  await initTheme();
89
94
 
90
95
  const params: SearchQueryParams = {
@@ -7,9 +7,9 @@
7
7
  * containing a `.git` *file* that points back at
8
8
  * `<parent-repo>/.git/worktrees/<name>/`.
9
9
  * - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
10
- * `merged` subdir mounted/cloned by `natives.isoStart`. These are ephemeral
11
- * `ensureIsolation` always `rm -rf`s the base before re-creating it, so
12
- * any leftover on disk is a leak from a crashed run.
10
+ * compact `m` subdir mounted/cloned by `natives.isoStart`. Legacy `merged`
11
+ * subdirs are still recognized. These are ephemeral; `ensureIsolation`
12
+ * removes the base before re-creating it, so leftovers are crashed runs.
13
13
  *
14
14
  * Legacy entries from before the encoding change keep working because git still
15
15
  * tracks them by branch name. This command exists to GC them on demand.
@@ -22,6 +22,8 @@ import * as git from "../utils/git";
22
22
 
23
23
  type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
24
24
 
25
+ const TASK_ISOLATION_MOUNT_DIRS = ["m", "merged"] as const;
26
+
25
27
  export interface WorktreeEntry {
26
28
  /** Absolute path to the worktree dir (or stray container) under `~/.omp/wt/`. */
27
29
  path: string;
@@ -209,8 +211,9 @@ async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
209
211
  if (gitStat?.isFile()) {
210
212
  return classifyPrCheckout(dir, gitEntry);
211
213
  }
212
- const mergedStat = await fs.stat(path.join(dir, "merged")).catch(() => null);
213
- if (mergedStat?.isDirectory()) {
214
+ for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
215
+ const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
216
+ if (!mountStat?.isDirectory()) continue;
214
217
  return {
215
218
  path: dir,
216
219
  kind: "task-isolation",
@@ -1,5 +1,6 @@
1
1
  import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
2
2
  import { runBenchCommand } from "../cli/bench-cli";
3
+ import { SERVICE_TIER_SETTING_VALUES } from "../config/service-tier";
3
4
 
4
5
  export default class Bench extends Command {
5
6
  static description =
@@ -14,16 +15,22 @@ export default class Bench extends Command {
14
15
  };
15
16
 
16
17
  static flags = {
17
- runs: Flags.integer({ description: "Requests per model (results are averaged)", default: 1 }),
18
+ runs: Flags.integer({ description: "Requests per model (results are averaged)", default: 10 }),
18
19
  "max-tokens": Flags.integer({ description: "Max output tokens per request", default: 512 }),
19
20
  prompt: Flags.string({ description: "Custom prompt text (default: bundled bench prompt)" }),
21
+ "service-tier": Flags.string({
22
+ description: "Service tier hint (default: configured `serviceTier` setting; `none` omits it)",
23
+ options: SERVICE_TIER_SETTING_VALUES,
24
+ }),
20
25
  json: Flags.boolean({ description: "Output JSON" }),
26
+ par: Flags.integer({ description: "Execute runs with N parallel queries/requests", default: 4 }),
21
27
  };
22
28
 
23
29
  static examples = [
24
30
  "# Compare two models\n omp bench anthropic/claude-opus-4-5 openai/gpt-5.2",
25
31
  "# Fuzzy selectors work\n omp bench opus sonnet",
26
32
  "# Average over 3 runs each\n omp bench opus gpt-5.2 --runs 3",
33
+ "# Force priority serving tier\n omp bench openai-codex/gpt-5.5:low --runs 10 --service-tier priority",
27
34
  "# Machine-readable output\n omp bench opus --json",
28
35
  ];
29
36
 
@@ -35,7 +42,9 @@ export default class Bench extends Command {
35
42
  runs: flags.runs,
36
43
  maxTokens: flags["max-tokens"],
37
44
  prompt: flags.prompt,
45
+ serviceTier: flags["service-tier"],
38
46
  json: flags.json,
47
+ par: flags.par,
39
48
  },
40
49
  });
41
50
  }
@@ -92,7 +92,7 @@
92
92
  },
93
93
  "prompt": {
94
94
  "type": "string",
95
- "description": "OAuth `prompt` parameter sent during authorization (default: \"consent\" so the provider always shows its account/consent screen; set to \"\" to omit)."
95
+ "description": "OAuth `prompt` parameter sent during authorization (default: omit unless the requested scope contains `offline_access`, which sends \"consent\"; set to \"\" to always omit)."
96
96
  }
97
97
  },
98
98
  "description": "Explicit OAuth client settings for servers that need them during /mcp reauth or initial connect."
@@ -122,11 +122,18 @@ type OllamaDiscoveredModelMetadata = {
122
122
  type LlamaCppDiscoveredServerMetadata = {
123
123
  contextWindow?: number;
124
124
  input?: ("text" | "image")[];
125
+ maxTokens?: "contextWindow";
126
+ };
127
+
128
+ type LlamaCppDiscoveredModelRuntimeMetadata = {
129
+ contextWindow: number;
130
+ maxTokens: number;
125
131
  };
126
132
 
127
133
  type LlamaCppModelListEntry = {
128
134
  id: string;
129
- contextWindow?: number;
135
+ runtimeContextWindow?: number;
136
+ trainingContextWindow?: number;
130
137
  };
131
138
 
132
139
  function toPositiveNumberOrUndefined(value: unknown): number | undefined {
@@ -142,7 +149,59 @@ function toPositiveNumberOrUndefined(value: unknown): number | undefined {
142
149
  return undefined;
143
150
  }
144
151
 
152
+ function isLlamaCppUnlimitedSentinel(value: unknown): boolean {
153
+ if (typeof value === "number") {
154
+ return value === -1;
155
+ }
156
+ if (typeof value === "string" && value.trim()) {
157
+ return Number(value) === -1;
158
+ }
159
+ return false;
160
+ }
161
+
162
+ /**
163
+ * llama.cpp `/props.default_generation_settings.params.{max_tokens,n_predict}`
164
+ * are per-request defaults the server applies when a client omits the field —
165
+ * clients can still raise them per call. Positive values therefore are NOT
166
+ * hard model caps; only the `-1` unlimited sentinel reliably tells us the
167
+ * server bounds generation by the runtime context window. Anything else
168
+ * leaves the discovery default in place.
169
+ */
170
+ function extractLlamaCppMaxTokens(payload: Record<string, unknown>): "contextWindow" | undefined {
171
+ const generationSettings = payload.default_generation_settings;
172
+ const params = isRecord(generationSettings) ? generationSettings.params : undefined;
173
+ const candidates = [
174
+ isRecord(params) ? params.max_tokens : undefined,
175
+ isRecord(params) ? params.n_predict : undefined,
176
+ isRecord(generationSettings) ? generationSettings.max_tokens : undefined,
177
+ isRecord(generationSettings) ? generationSettings.n_predict : undefined,
178
+ payload.max_tokens,
179
+ payload.n_predict,
180
+ ];
181
+ return candidates.some(isLlamaCppUnlimitedSentinel) ? "contextWindow" : undefined;
182
+ }
183
+
184
+ function resolveLlamaCppMaxTokens(contextWindow: number, maxTokens: "contextWindow" | undefined): number {
185
+ return maxTokens === "contextWindow"
186
+ ? contextWindow
187
+ : Math.min(contextWindow, maxTokens ?? DISCOVERY_DEFAULT_MAX_TOKENS);
188
+ }
189
+
190
+ function extractOllamaRuntimeContextWindow(payload: Record<string, unknown>): number | undefined {
191
+ const parameters = payload.parameters;
192
+ if (typeof parameters !== "string") {
193
+ return undefined;
194
+ }
195
+ const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
196
+ return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
197
+ }
198
+
145
199
  function extractOllamaContextWindow(payload: Record<string, unknown>): number | undefined {
200
+ const runtimeContextWindow = extractOllamaRuntimeContextWindow(payload);
201
+ if (runtimeContextWindow !== undefined) {
202
+ return runtimeContextWindow;
203
+ }
204
+
146
205
  const modelInfo = payload.model_info;
147
206
  if (isRecord(modelInfo)) {
148
207
  for (const [key, value] of Object.entries(modelInfo)) {
@@ -155,12 +214,7 @@ function extractOllamaContextWindow(payload: Record<string, unknown>): number |
155
214
  }
156
215
  }
157
216
 
158
- const parameters = payload.parameters;
159
- if (typeof parameters !== "string") {
160
- return undefined;
161
- }
162
- const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
163
- return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
217
+ return undefined;
164
218
  }
165
219
 
166
220
  function extractLlamaCppContextWindow(payload: Record<string, unknown>): number | undefined {
@@ -174,12 +228,17 @@ function extractLlamaCppContextWindow(payload: Record<string, unknown>): number
174
228
  return toPositiveNumberOrUndefined(payload.n_ctx);
175
229
  }
176
230
 
177
- function extractLlamaCppModelContextWindow(item: Record<string, unknown>): number | undefined {
231
+ function extractLlamaCppModelContextWindows(
232
+ item: Record<string, unknown>,
233
+ ): Pick<LlamaCppModelListEntry, "runtimeContextWindow" | "trainingContextWindow"> {
178
234
  const meta = item.meta;
179
235
  if (!isRecord(meta)) {
180
- return undefined;
236
+ return {};
181
237
  }
182
- return toPositiveNumberOrUndefined(meta.n_ctx) ?? toPositiveNumberOrUndefined(meta.n_ctx_train);
238
+ return {
239
+ runtimeContextWindow: toPositiveNumberOrUndefined(meta.n_ctx),
240
+ trainingContextWindow: toPositiveNumberOrUndefined(meta.n_ctx_train),
241
+ };
183
242
  }
184
243
 
185
244
  function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
@@ -190,7 +249,7 @@ function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
190
249
  if (!isRecord(item) || typeof item.id !== "string" || !item.id) {
191
250
  return [];
192
251
  }
193
- return [{ id: item.id, contextWindow: extractLlamaCppModelContextWindow(item) }];
252
+ return [{ id: item.id, ...extractLlamaCppModelContextWindows(item) }];
194
253
  });
195
254
  }
196
255
 
@@ -338,6 +397,7 @@ async function discoverLlamaCppServerMetadata(
338
397
  }
339
398
  return {
340
399
  contextWindow: extractLlamaCppContextWindow(payload),
400
+ maxTokens: extractLlamaCppMaxTokens(payload),
341
401
  input: extractLlamaCppInputCapabilities(payload),
342
402
  };
343
403
  } catch {
@@ -378,7 +438,11 @@ export async function discoverLlamaCppModels(
378
438
  for (const item of models) {
379
439
  const { id } = item;
380
440
  if (!id) continue;
381
- const contextWindow = item.contextWindow ?? serverMetadata?.contextWindow ?? DISCOVERY_DEFAULT_CONTEXT_WINDOW;
441
+ const contextWindow =
442
+ item.runtimeContextWindow ??
443
+ serverMetadata?.contextWindow ??
444
+ item.trainingContextWindow ??
445
+ DISCOVERY_DEFAULT_CONTEXT_WINDOW;
382
446
  discovered.push(
383
447
  buildModel({
384
448
  id,
@@ -391,7 +455,7 @@ export async function discoverLlamaCppModels(
391
455
  imageInputDecoder: "stb",
392
456
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
393
457
  contextWindow,
394
- maxTokens: Math.min(contextWindow, DISCOVERY_DEFAULT_MAX_TOKENS),
458
+ maxTokens: resolveLlamaCppMaxTokens(contextWindow, serverMetadata?.maxTokens),
395
459
  headers,
396
460
  compat: {
397
461
  supportsStore: false,
@@ -404,23 +468,37 @@ export async function discoverLlamaCppModels(
404
468
  return discovered;
405
469
  }
406
470
 
407
- export async function discoverLlamaCppModelContextWindow(
471
+ export async function discoverLlamaCppModelRuntimeMetadata(
408
472
  model: Pick<Model<Api>, "provider" | "id" | "baseUrl" | "headers">,
409
473
  ctx: DiscoveryContext,
410
- ): Promise<number | undefined> {
474
+ ): Promise<LlamaCppDiscoveredModelRuntimeMetadata | undefined> {
411
475
  const baseUrl = normalizeLlamaCppBaseUrl(model.baseUrl);
412
476
  const modelsUrl = `${baseUrl}/models`;
413
477
  const baseHeaders: Record<string, string> = { ...(model.headers ?? {}) };
414
478
  const attempt = async (headers: Record<string, string>) => {
415
- const response = await ctx.fetch(modelsUrl, {
416
- headers,
417
- signal: AbortSignal.timeout(250),
418
- });
479
+ const [response, serverMetadata] = await Promise.all([
480
+ ctx.fetch(modelsUrl, {
481
+ headers,
482
+ signal: AbortSignal.timeout(250),
483
+ }),
484
+ discoverLlamaCppServerMetadata(ctx, baseUrl, headers),
485
+ ]);
419
486
  if (!response.ok) {
420
487
  return undefined;
421
488
  }
422
489
  const entries = parseLlamaCppModelList(await response.json());
423
- return entries.find(entry => entry.id === model.id)?.contextWindow;
490
+ const entry = entries.find(entry => entry.id === model.id);
491
+ if (!entry) {
492
+ return undefined;
493
+ }
494
+ const contextWindow = entry.runtimeContextWindow ?? serverMetadata?.contextWindow ?? entry.trainingContextWindow;
495
+ if (contextWindow === undefined) {
496
+ return undefined;
497
+ }
498
+ return {
499
+ contextWindow,
500
+ maxTokens: resolveLlamaCppMaxTokens(contextWindow, serverMetadata?.maxTokens),
501
+ };
424
502
  };
425
503
  try {
426
504
  const apiKey = await ctx.getBearerApiKeyResolver(model.provider);
@@ -75,7 +75,7 @@ import {
75
75
  DISCOVERY_DEFAULT_MAX_TOKENS,
76
76
  type DiscoveryContext,
77
77
  type DiscoveryProviderConfig,
78
- discoverLlamaCppModelContextWindow,
78
+ discoverLlamaCppModelRuntimeMetadata,
79
79
  discoverModelsByProviderType,
80
80
  getImplicitOllamaBaseUrl,
81
81
  getOllamaContextLengthOverride,
@@ -873,10 +873,11 @@ export class ModelRegistry {
873
873
  if (!isLlamaCppDiscovery) {
874
874
  return model;
875
875
  }
876
- const contextWindow = await discoverLlamaCppModelContextWindow(model, this.#nonResolvingDiscoveryContext());
877
- if (contextWindow === undefined) {
876
+ const runtimeMetadata = await discoverLlamaCppModelRuntimeMetadata(model, this.#nonResolvingDiscoveryContext());
877
+ if (runtimeMetadata === undefined) {
878
878
  return this.find(model.provider, model.id) ?? model;
879
879
  }
880
+ const { contextWindow, maxTokens } = runtimeMetadata;
880
881
  const current = this.find(model.provider, model.id) ?? model;
881
882
  const override = this.#resolveLiveModelOverride(current);
882
883
  const customModel = this.#resolveLiveCustomModelOverlay(current);
@@ -888,13 +889,19 @@ export class ModelRegistry {
888
889
  ) {
889
890
  patch.contextWindow = contextWindow;
890
891
  }
891
- const maxTokens = Math.min(contextWindow, DISCOVERY_DEFAULT_MAX_TOKENS);
892
+ const effectiveContextWindow =
893
+ override?.contextWindow ??
894
+ customModel?.contextWindow ??
895
+ patch.contextWindow ??
896
+ current.contextWindow ??
897
+ contextWindow;
898
+ const effectiveMaxTokens = Math.min(maxTokens, effectiveContextWindow);
892
899
  if (
893
900
  override?.maxTokens === undefined &&
894
901
  customModel?.maxTokens === undefined &&
895
- current.maxTokens !== maxTokens
902
+ current.maxTokens !== effectiveMaxTokens
896
903
  ) {
897
- patch.maxTokens = maxTokens;
904
+ patch.maxTokens = effectiveMaxTokens;
898
905
  }
899
906
  if (patch.contextWindow === undefined && patch.maxTokens === undefined) {
900
907
  return current;
@@ -317,8 +317,11 @@ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
317
317
  // `>` must sit outside quoted regions (so `echo "a -> b"` passes) and be
318
318
  // followed by a plausible filename — including `$VAR` targets; `>|`
319
319
  // (clobber) counts as a redirect; `>&2`/`2>&1` style fd duplication is
320
- // not matched.
321
- pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+(?:[^\"'>]|\"[^\"]*\"|'[^']*')*(?<!\\|)>{1,2}\\|?\\s*[$\\w./~\"'-]",
320
+ // not matched. Allowed device sinks are consumed while looking for later
321
+ // real file redirects because the write tool cannot replace shell
322
+ // output/discard targets.
323
+ pattern:
324
+ "^\\s*(echo|printf|cat\\s*<<)\\s+(?:(?:[^\"'>]|\"[^\"]*\"|'[^']*')|(?<!\\|)>{1,2}\\|?\\s*(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))*(?<!\\|)>{1,2}\\|?\\s*(?!(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))[$\\w./~\"'-]",
322
325
  tool: "write",
323
326
  message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
324
327
  },
@@ -27,6 +27,7 @@ import { ToolError } from "../../tools/tool-errors";
27
27
  import { generateDiffString } from "../diff";
28
28
  import { getFileSnapshotStore } from "../file-snapshot-store";
29
29
  import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
30
+ import { pruneOversizedEditSnapshots } from "../snapshot-details";
30
31
  import { nativeBlockResolver } from "./block-resolver";
31
32
  import { HashlineFilesystem } from "./filesystem";
32
33
  import { hashPatchInput, NOOP_HARD_LIMIT, recordNoopEdit, resetNoopEdit } from "./noop-loop-guard";
@@ -114,17 +115,22 @@ function renderSection(
114
115
  if (result.op === "delete") {
115
116
  const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
116
117
  content: [{ type: "text", text: `Deleted ${result.path}` }],
117
- details: {
118
+ details: pruneOversizedEditSnapshots({
118
119
  diff: "",
119
120
  op: "delete",
120
121
  path: result.path,
121
122
  oldText: result.before,
122
123
  meta: outputMeta().get(),
123
- },
124
+ }),
124
125
  };
125
126
  return {
126
127
  toolResult,
127
- perFileResult: { path: result.path, diff: "", op: "delete", oldText: result.before },
128
+ perFileResult: pruneOversizedEditSnapshots({
129
+ path: result.path,
130
+ diff: "",
131
+ op: "delete",
132
+ oldText: result.before,
133
+ }),
128
134
  };
129
135
  }
130
136
 
@@ -161,7 +167,7 @@ function renderSection(
161
167
  text: `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`,
162
168
  },
163
169
  ],
164
- details: {
170
+ details: pruneOversizedEditSnapshots({
165
171
  diff: diff.diff,
166
172
  firstChangedLine,
167
173
  diagnostics,
@@ -172,9 +178,9 @@ function renderSection(
172
178
  oldText: result.before,
173
179
  newText: result.after,
174
180
  meta,
175
- },
181
+ }),
176
182
  },
177
- perFileResult: {
183
+ perFileResult: pruneOversizedEditSnapshots({
178
184
  path: result.moveDest ?? result.path,
179
185
  diff: diff.diff,
180
186
  firstChangedLine,
@@ -184,7 +190,7 @@ function renderSection(
184
190
  sourcePath: result.moveDest ? sourcePath : undefined,
185
191
  oldText: result.before,
186
192
  newText: result.after,
187
- },
193
+ }),
188
194
  };
189
195
  }
190
196
 
@@ -263,10 +269,10 @@ export async function executeHashlineSingle(
263
269
  .join("\n\n"),
264
270
  },
265
271
  ],
266
- details: {
272
+ details: pruneOversizedEditSnapshots({
267
273
  diff: rendered.map(r => r.toolResult.details?.diff ?? "").join("\n"),
268
274
  perFileResults: rendered.map(r => r.perFileResult),
269
- },
275
+ }),
270
276
  };
271
277
  }
272
278