@oh-my-pi/pi-coding-agent 16.2.5 → 16.2.7

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 (93) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/cli.js +3089 -3104
  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/service-tier.d.ts +39 -24
  7. package/dist/types/config/settings-schema.d.ts +37 -37
  8. package/dist/types/edit/index.d.ts +1 -0
  9. package/dist/types/edit/renderer.d.ts +4 -0
  10. package/dist/types/edit/snapshot-details.d.ts +33 -0
  11. package/dist/types/mcp/config-writer.d.ts +48 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  13. package/dist/types/mcp/types.d.ts +3 -0
  14. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  15. package/dist/types/session/agent-session.d.ts +37 -13
  16. package/dist/types/session/messages.d.ts +1 -1
  17. package/dist/types/session/session-context.d.ts +2 -2
  18. package/dist/types/session/session-entries.d.ts +2 -2
  19. package/dist/types/session/session-manager.d.ts +5 -6
  20. package/dist/types/system-prompt.test.d.ts +1 -0
  21. package/dist/types/task/executor.d.ts +6 -6
  22. package/dist/types/task/types.d.ts +6 -0
  23. package/dist/types/task/worktree.d.ts +32 -6
  24. package/dist/types/tiny/title-client.d.ts +5 -1
  25. package/dist/types/tools/index.d.ts +3 -3
  26. package/dist/types/utils/git.d.ts +17 -0
  27. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  28. package/dist/types/web/search/types.d.ts +1 -1
  29. package/package.json +12 -12
  30. package/src/cli/args.ts +32 -1
  31. package/src/cli/bench-cli.ts +97 -22
  32. package/src/cli/tiny-models-cli.ts +18 -4
  33. package/src/cli/web-search-cli.ts +6 -1
  34. package/src/commands/bench.ts +10 -1
  35. package/src/config/mcp-schema.json +11 -2
  36. package/src/config/model-discovery.ts +66 -8
  37. package/src/config/model-registry.ts +13 -6
  38. package/src/config/service-tier.ts +85 -56
  39. package/src/config/settings-schema.ts +42 -36
  40. package/src/config/settings.ts +47 -0
  41. package/src/edit/hashline/execute.ts +15 -9
  42. package/src/edit/index.ts +19 -6
  43. package/src/edit/modes/patch.ts +3 -2
  44. package/src/edit/modes/replace.ts +3 -2
  45. package/src/edit/renderer.ts +4 -0
  46. package/src/edit/snapshot-details.ts +77 -0
  47. package/src/eval/agent-bridge.ts +4 -2
  48. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  49. package/src/internal-urls/docs-index.generated.txt +1 -1
  50. package/src/main.ts +1 -1
  51. package/src/mcp/config-writer.ts +121 -0
  52. package/src/mcp/config.ts +10 -6
  53. package/src/mcp/oauth-flow.ts +10 -8
  54. package/src/mcp/transports/stdio.ts +9 -17
  55. package/src/mcp/types.ts +3 -0
  56. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  57. package/src/modes/components/extensions/state-manager.ts +24 -3
  58. package/src/modes/controllers/event-controller.ts +24 -8
  59. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  60. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  61. package/src/prompts/bench.md +4 -10
  62. package/src/prompts/tools/irc.md +19 -29
  63. package/src/prompts/tools/job.md +8 -14
  64. package/src/prompts/tools/lsp.md +19 -30
  65. package/src/prompts/tools/task.md +42 -62
  66. package/src/sdk.ts +13 -11
  67. package/src/session/agent-session.ts +196 -76
  68. package/src/session/messages.ts +1 -1
  69. package/src/session/session-context.ts +4 -4
  70. package/src/session/session-entries.ts +2 -2
  71. package/src/session/session-listing.ts +9 -8
  72. package/src/session/session-loader.ts +98 -3
  73. package/src/session/session-manager.ts +43 -6
  74. package/src/slash-commands/builtin-registry.ts +2 -10
  75. package/src/subprocess/worker-client.ts +12 -4
  76. package/src/system-prompt.test.ts +158 -0
  77. package/src/system-prompt.ts +69 -26
  78. package/src/task/executor.ts +23 -16
  79. package/src/task/index.ts +7 -5
  80. package/src/task/isolation-runner.ts +15 -1
  81. package/src/task/types.ts +6 -0
  82. package/src/task/worktree.ts +219 -38
  83. package/src/tiny/title-client.ts +19 -13
  84. package/src/tools/index.ts +3 -3
  85. package/src/tools/irc.ts +9 -3
  86. package/src/tools/path-utils.ts +4 -2
  87. package/src/tools/read.ts +28 -28
  88. package/src/utils/file-mentions.ts +10 -1
  89. package/src/utils/git.ts +38 -0
  90. package/src/web/search/index.ts +14 -8
  91. package/src/web/search/providers/duckduckgo.ts +150 -78
  92. package/src/web/search/providers/gemini.ts +268 -185
  93. package/src/web/search/types.ts +1 -1
@@ -1,4 +1,4 @@
1
- import type { ImageContent, Message, MessageAttribution, ServiceTier, TextContent } from "@oh-my-pi/pi-ai";
1
+ import type { ImageContent, Message, MessageAttribution, ServiceTierByFamily, TextContent } from "@oh-my-pi/pi-ai";
2
2
  import { ArtifactManager } from "./artifacts";
3
3
  import { type BlobPutOptions, type BlobPutResult } from "./blob-store";
4
4
  import { type BashExecutionMessage, type CustomMessage, type FileMentionMessage, type HookMessage, type PythonExecutionMessage } from "./messages";
@@ -75,10 +75,9 @@ export declare class SessionManager {
75
75
  /** Flush pending writes. Call before switching sessions or on shutdown. */
76
76
  flush(): Promise<void>;
77
77
  /**
78
- * Synchronously flush all in-memory entries to disk. Use when the process may
79
- * exit before an async flush settles (Ctrl+C in the TUI). Software-crash
80
- * durable; not atomic and not power-loss safe a same-process crash never
81
- * lands mid-`writeFileSync`.
78
+ * Synchronously makes the current append-only session durable. Avoid rewriting
79
+ * an already-current file: large restored sessions can contain GiB of compacted
80
+ * history, and Ctrl+C must not rebuild the whole JSONL string just to flush.
82
81
  */
83
82
  flushSync(): void;
84
83
  /** Flush, then close the append writer. */
@@ -142,7 +141,7 @@ export declare class SessionManager {
142
141
  appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage | PythonExecutionMessage | FileMentionMessage): string;
143
142
  /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
144
143
  appendThinkingLevelChange(thinkingLevel?: string, configured?: string): string;
145
- appendServiceTierChange(serviceTier: ServiceTier | null): string;
144
+ appendServiceTierChange(serviceTier: ServiceTierByFamily | null): string;
146
145
  appendModeChange(mode: string, data?: Record<string, unknown>): string;
147
146
  /**
148
147
  * Append a model change as a child of the current leaf, then advance the leaf.
@@ -0,0 +1 @@
1
+ export {};
@@ -4,7 +4,7 @@
4
4
  * Runs each subagent on the main thread and forwards AgentEvents for progress tracking.
5
5
  */
6
6
  import type { AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
- import type { ServiceTier } from "@oh-my-pi/pi-ai";
7
+ import type { ServiceTierByFamily } from "@oh-my-pi/pi-ai";
8
8
  import type { Rule } from "../capability/rule";
9
9
  import { ModelRegistry } from "../config/model-registry";
10
10
  import type { PromptTemplate } from "../config/prompt-templates";
@@ -123,12 +123,12 @@ export interface ExecutorOptions {
123
123
  modelRegistry?: ModelRegistry;
124
124
  settings?: Settings;
125
125
  /**
126
- * Parent session's live effective service tier, the source of truth for a
127
- * subagent whose `serviceTierSubagent` is `"inherit"`. `null` = the parent
126
+ * Parent session's live per-family service tiers, the source of truth for a
127
+ * subagent whose `tier.subagent` is `"inherit"`. `null` = the parent
128
128
  * explicitly has no tier (e.g. `/fast off`); omitted = no live session, so
129
- * inherit falls back to the configured `serviceTier` setting.
129
+ * inherit falls back to the subagent's configured `tier.*` settings.
130
130
  */
131
- parentServiceTier?: ServiceTier | null;
131
+ parentServiceTier?: ServiceTierByFamily | null;
132
132
  /** Override local:// protocol options so subagent shares parent's local:// root */
133
133
  localProtocolOptions?: LocalProtocolOptions;
134
134
  /**
@@ -191,7 +191,7 @@ export declare function finalizeSubprocessOutput(args: FinalizeSubprocessOutputA
191
191
  * Create proxy tools that reuse the parent's MCP connections.
192
192
  */
193
193
  export declare function createMCPProxyTools(mcpManager: MCPManager): CustomTool[];
194
- export declare function createSubagentSettings(baseSettings: Settings, overrides?: Partial<Record<SettingPath, unknown>>, inheritedServiceTier?: ServiceTier | null): Settings;
194
+ export declare function createSubagentSettings(baseSettings: Settings, overrides?: Partial<Record<SettingPath, unknown>>, inheritedServiceTier?: ServiceTierByFamily | null): Settings;
195
195
  export declare function finalizeSubagentLifecycle(args: {
196
196
  id: string;
197
197
  session: AgentSession;
@@ -334,6 +334,12 @@ export interface SingleResult {
334
334
  patchPath?: string;
335
335
  /** Branch name for isolated branch-mode output */
336
336
  branchName?: string;
337
+ /**
338
+ * Baseline commit SHA the task branch was created from. Passed to
339
+ * `mergeTaskBranches` so cherry-pick uses the inclusive range
340
+ * `branchBaseSha..branchName` and preserves every agent commit's message.
341
+ */
342
+ branchBaseSha?: string;
337
343
  /** Nested repo patches to apply after parent merge */
338
344
  nestedPatches?: NestedRepoPatch[];
339
345
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
@@ -80,11 +80,32 @@ export declare function cleanupIsolation(handle: IsolationHandle): Promise<void>
80
80
  export interface CommitToBranchResult {
81
81
  branchName?: string;
82
82
  nestedPatches: NestedRepoPatch[];
83
+ /**
84
+ * SHA of the parent-repo commit the task branch was created on top of, so
85
+ * {@link mergeTaskBranches} can cherry-pick the range `baseSha..branchName`
86
+ * and preserve every agent commit's message and author.
87
+ */
88
+ baseSha?: string;
83
89
  }
84
90
  /**
85
- * Commit task-only changes to a new branch.
86
- * Only root repo changes go on the branch. Nested repo patches are returned
87
- * separately since the parent git can't track files inside gitlinks.
91
+ * Capture task-only changes from the isolation worktree onto a parent-repo
92
+ * branch named `omp/task/${taskId}`. Only root-repo changes go on the branch;
93
+ * nested-repo patches are returned separately because the parent git can't
94
+ * track files inside gitlinks.
95
+ *
96
+ * If the agent committed inside isolation (HEAD moved past
97
+ * `baseline.root.headCommit`), clean-baseline runs fetch the raw commit range
98
+ * into the parent repo and later cherry-pick `baseSha..branchName`, preserving
99
+ * every message and author verbatim. Dirty-baseline runs rewrite each agent
100
+ * commit against the captured baseline WIP before committing it to the task
101
+ * branch, so user staged/unstaged/untracked changes present at isolation
102
+ * start are not replayed into the parent commit history.
103
+ *
104
+ * If the agent did not commit, the captured delta is collapsed onto a single
105
+ * branch commit with an AI-generated (or fallback) message — the legacy
106
+ * behaviour.
107
+ *
108
+ * Returns `null` when no root or nested changes exist.
88
109
  */
89
110
  export declare function commitToBranch(isolationDir: string, baseline: WorktreeBaseline, taskId: string, description: string | undefined, commitMessage?: (diff: string) => Promise<string | null>): Promise<CommitToBranchResult | null>;
90
111
  export interface MergeBranchResult {
@@ -95,14 +116,19 @@ export interface MergeBranchResult {
95
116
  stashConflict?: string;
96
117
  }
97
118
  /**
98
- * Cherry-pick task branch commits sequentially onto HEAD.
99
- * Each branch has a single commit that gets replayed cleanly.
100
- * Stops on first conflict and reports which branches succeeded.
119
+ * Cherry-pick task branch commits sequentially onto HEAD. When `baseSha` is
120
+ * provided the cherry-pick uses the inclusive range `baseSha..branchName`,
121
+ * replaying every commit individually and preserving each commit's message
122
+ * and author. When omitted, the branch is cherry-picked as a single commit
123
+ * (legacy callers).
124
+ *
125
+ * Stops on the first conflict and reports which branches succeeded.
101
126
  */
102
127
  export declare function mergeTaskBranches(repoRoot: string, branches: Array<{
103
128
  branchName: string;
104
129
  taskId: string;
105
130
  description?: string;
131
+ baseSha?: string;
106
132
  }>): Promise<MergeBranchResult>;
107
133
  /** Clean up temporary task branches. */
108
134
  export declare function cleanupTaskBranches(repoRoot: string, branches: string[]): Promise<void>;
@@ -1,5 +1,9 @@
1
1
  import { type RefCountedWorkerHandle, type SpawnedSubprocess } from "../subprocess/worker-client";
2
2
  import type { TinyTitleProgressEvent, TinyTitleWorkerInbound, TinyTitleWorkerOutbound } from "./title-protocol";
3
+ export interface TinyTitleDownloadResult {
4
+ ok: boolean;
5
+ error?: string;
6
+ }
3
7
  export interface TinyTitleDownloadOptions {
4
8
  signal?: AbortSignal;
5
9
  onProgress?: (event: TinyTitleProgressEvent) => void;
@@ -52,7 +56,7 @@ export declare class TinyTitleClient {
52
56
  maxTokens?: number;
53
57
  signal?: AbortSignal;
54
58
  }): Promise<string | null>;
55
- downloadModel(modelKey: string, options?: TinyTitleDownloadOptions): Promise<boolean>;
59
+ downloadModel(modelKey: string, options?: TinyTitleDownloadOptions): Promise<TinyTitleDownloadResult>;
56
60
  terminate(): Promise<void>;
57
61
  }
58
62
  export declare const tinyTitleClient: TinyTitleClient;
@@ -1,6 +1,6 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
- import type { FetchImpl, ImageContent, Model, ServiceTier, ToolChoice } from "@oh-my-pi/pi-ai";
3
+ import type { FetchImpl, ImageContent, Model, ServiceTierByFamily, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import type { AsyncJobManager } from "../async/job-manager";
5
5
  import type { Rule } from "../capability/rule";
6
6
  import type { PromptTemplate } from "../config/prompt-templates";
@@ -191,8 +191,8 @@ export interface ToolSession {
191
191
  getActiveModelString?: () => string | undefined;
192
192
  /** Get the current session model object (provider/api capabilities), regardless of how it was chosen. */
193
193
  getActiveModel?: () => Model | undefined;
194
- /** Get the session's live effective service tier (undefined = none). Source of truth for subagent `serviceTierSubagent: inherit`. */
195
- getServiceTier?: () => ServiceTier | undefined;
194
+ /** Get the session's live per-family service tiers (undefined = none). Source of truth for subagent `tier.subagent: inherit`. */
195
+ getServiceTierByFamily?: () => ServiceTierByFamily | undefined;
196
196
  /** Auth storage for passing to subagents (avoids re-discovery) */
197
197
  authStorage?: import("../session/auth-storage").AuthStorage;
198
198
  /** Model registry for passing to subagents (avoids re-discovery) */
@@ -63,8 +63,18 @@ export interface StatusOptions {
63
63
  readonly untrackedFiles?: "all" | "no" | "normal";
64
64
  readonly z?: boolean;
65
65
  }
66
+ export interface CommitAuthor {
67
+ readonly date?: string;
68
+ readonly email: string;
69
+ readonly name: string;
70
+ }
71
+ export interface CommitDetails {
72
+ readonly author: CommitAuthor;
73
+ readonly message: string;
74
+ }
66
75
  export interface CommitOptions {
67
76
  readonly allowEmpty?: boolean;
77
+ readonly author?: CommitAuthor;
68
78
  readonly files?: readonly string[];
69
79
  readonly signal?: AbortSignal;
70
80
  }
@@ -78,6 +88,7 @@ export interface PatchOptions {
78
88
  readonly cached?: boolean;
79
89
  readonly check?: boolean;
80
90
  readonly env?: Record<string, string | undefined>;
91
+ readonly threeWay?: boolean;
81
92
  readonly signal?: AbortSignal;
82
93
  }
83
94
  export interface RestoreOptions {
@@ -195,12 +206,18 @@ export declare const show: ((cwd: string, revision: string, options?: {
195
206
  /** Get the path prefix of the current directory relative to the repo root. */
196
207
  prefix(cwd: string, signal?: AbortSignal): Promise<string>;
197
208
  };
209
+ /** Read commit message and author metadata for replay/rewrite flows. */
210
+ export declare function commitDetails(cwd: string, revision: string, signal?: AbortSignal): Promise<CommitDetails>;
198
211
  export declare const log: {
199
212
  /** Recent commit subjects (one-line each). */
200
213
  subjects(cwd: string, count: number, signal?: AbortSignal): Promise<string[]>;
201
214
  /** Recent commits as `<short-sha> <subject>` onelines. */
202
215
  onelines(cwd: string, count: number, signal?: AbortSignal): Promise<string[]>;
203
216
  };
217
+ export declare const revList: {
218
+ /** Commits in `base..head`, oldest first. */
219
+ range(cwd: string, base: string, head: string, signal?: AbortSignal): Promise<string[]>;
220
+ };
204
221
  export declare const branch: {
205
222
  /** Current branch name, or null if detached/unavailable. */
206
223
  current(cwd: string, signal?: AbortSignal): Promise<string | null>;
@@ -2,9 +2,9 @@ import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
2
  import type { SearchResponse } from "../../../web/search/types";
3
3
  import type { SearchParams } from "./base";
4
4
  import { SearchProvider } from "./base";
5
- /** Execute DuckDuckGo Instant Answer API search. */
5
+ /** Execute a DuckDuckGo web search via the no-JS HTML frontend. */
6
6
  export declare function searchDuckDuckGo(params: SearchParams): Promise<SearchResponse>;
7
- /** Search provider for DuckDuckGo Instant Answer API. */
7
+ /** Search provider for DuckDuckGo (no API key required). */
8
8
  export declare class DuckDuckGoProvider extends SearchProvider {
9
9
  readonly id = "duckduckgo";
10
10
  readonly label = "DuckDuckGo";
@@ -78,7 +78,7 @@ export declare const SEARCH_PROVIDER_OPTIONS: readonly [{
78
78
  }, {
79
79
  readonly value: "duckduckgo";
80
80
  readonly label: "DuckDuckGo";
81
- readonly description: "Uses DuckDuckGo Instant Answer API (no API key)";
81
+ readonly description: "Scrapes the DuckDuckGo HTML frontend (no API key)";
82
82
  }];
83
83
  /** Supported web search providers (every option except `auto`). */
84
84
  export type SearchProviderId = Exclude<(typeof SEARCH_PROVIDER_OPTIONS)[number]["value"], "auto">;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.2.5",
4
+ "version": "16.2.7",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -55,17 +55,17 @@
55
55
  "@agentclientprotocol/sdk": "0.25.0",
56
56
  "@babel/parser": "^7.29.7",
57
57
  "@mozilla/readability": "^0.6.0",
58
- "@oh-my-pi/hashline": "16.2.5",
59
- "@oh-my-pi/omp-stats": "16.2.5",
60
- "@oh-my-pi/pi-agent-core": "16.2.5",
61
- "@oh-my-pi/pi-ai": "16.2.5",
62
- "@oh-my-pi/pi-catalog": "16.2.5",
63
- "@oh-my-pi/pi-mnemopi": "16.2.5",
64
- "@oh-my-pi/pi-natives": "16.2.5",
65
- "@oh-my-pi/pi-tui": "16.2.5",
66
- "@oh-my-pi/pi-utils": "16.2.5",
67
- "@oh-my-pi/pi-wire": "16.2.5",
68
- "@oh-my-pi/snapcompact": "16.2.5",
58
+ "@oh-my-pi/hashline": "16.2.7",
59
+ "@oh-my-pi/omp-stats": "16.2.7",
60
+ "@oh-my-pi/pi-agent-core": "16.2.7",
61
+ "@oh-my-pi/pi-ai": "16.2.7",
62
+ "@oh-my-pi/pi-catalog": "16.2.7",
63
+ "@oh-my-pi/pi-mnemopi": "16.2.7",
64
+ "@oh-my-pi/pi-natives": "16.2.7",
65
+ "@oh-my-pi/pi-tui": "16.2.7",
66
+ "@oh-my-pi/pi-utils": "16.2.7",
67
+ "@oh-my-pi/pi-wire": "16.2.7",
68
+ "@oh-my-pi/snapcompact": "16.2.7",
69
69
  "@opentelemetry/api": "^1.9.1",
70
70
  "@opentelemetry/context-async-hooks": "^2.7.1",
71
71
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
package/src/cli/args.ts CHANGED
@@ -94,6 +94,35 @@ const PARSE_DEPS: ParseDeps = {
94
94
  thinkingEfforts: CLI_THINKING_LEVELS,
95
95
  };
96
96
 
97
+ const WINDOWS_PATH_VALUE_FLAGS: ReadonlySet<string> = new Set(["--extension", "-e", "--hook"]);
98
+ const WINDOWS_PATH_START_RE =
99
+ /^(?:[A-Za-z]:[\\/]|\\\\[?]\\(?:[A-Za-z]:[\\/]|UNC[\\/])|\\\\[^\\/]+[\\/][^\\/]+[\\/]|\/\/[?]\/(?:[A-Za-z]:\/|UNC\/)|\/\/[^/]+\/[^/]+\/)/;
100
+ const WINDOWS_MODULE_PATH_SUFFIX_RE = /\.(?:[cm]?[jt]sx?)$/i;
101
+
102
+ function consumeBuiltInStringValue(flag: string, args: string[], valueIndex: number): { value: string; index: number } {
103
+ const value = args[valueIndex];
104
+ if (
105
+ value === undefined ||
106
+ !WINDOWS_PATH_VALUE_FLAGS.has(flag) ||
107
+ !WINDOWS_PATH_START_RE.test(value) ||
108
+ WINDOWS_MODULE_PATH_SUFFIX_RE.test(value)
109
+ ) {
110
+ return { value: value ?? "", index: valueIndex };
111
+ }
112
+
113
+ let candidate = value;
114
+ for (let index = valueIndex + 1; index < args.length; index++) {
115
+ const next = args[index];
116
+ if (next === PROFILE_BOOTSTRAP_BOUNDARY_ARG || next.startsWith("-")) break;
117
+ candidate += ` ${next}`;
118
+ if (WINDOWS_MODULE_PATH_SUFFIX_RE.test(candidate)) {
119
+ return { value: candidate, index };
120
+ }
121
+ }
122
+
123
+ return { value, index: valueIndex };
124
+ }
125
+
97
126
  export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
98
127
  // Work on a copy: the `--option=value` handling below splices the value
99
128
  // into the array, and callers reuse the same argv (the post-extension
@@ -161,7 +190,9 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
161
190
  // here only when its boolean extension is NOT loaded) would otherwise swallow
162
191
  // the marker as its value and drop the user's trailing message.
163
192
  if (i + 1 < args.length && args[i + 1] !== PROFILE_BOOTSTRAP_BOUNDARY_ARG) {
164
- STRING_SETTERS[arg](result, args[++i], PARSE_DEPS);
193
+ const consumed = consumeBuiltInStringValue(arg, args, i + 1);
194
+ i = consumed.index;
195
+ STRING_SETTERS[arg](result, consumed.value, PARSE_DEPS);
165
196
  }
166
197
  } else if (OPTIONAL_VALUE_FLAGS.has(arg)) {
167
198
  const config = OPTIONAL_FLAGS[arg];
@@ -8,9 +8,12 @@ import type {
8
8
  Context,
9
9
  Effort,
10
10
  Model,
11
+ ProviderSessionState,
12
+ ServiceTier,
13
+ ServiceTierByFamily,
11
14
  SimpleStreamOptions,
12
15
  } from "@oh-my-pi/pi-ai";
13
- import { streamSimple } from "@oh-my-pi/pi-ai";
16
+ import { resolveModelServiceTier, streamSimple } from "@oh-my-pi/pi-ai";
14
17
  import { buildModelProviderPriorityRank, type CanonicalModelVariant } from "@oh-my-pi/pi-catalog/identity";
15
18
  import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
16
19
  import { formatDuration, getProjectDir } from "@oh-my-pi/pi-utils";
@@ -23,12 +26,14 @@ import {
23
26
  getModelMatchPreferences,
24
27
  resolveCliModel,
25
28
  } from "../config/model-resolver";
29
+ import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
26
30
  import { Settings } from "../config/settings";
27
31
  import benchPrompt from "../prompts/bench.md" with { type: "text" };
28
32
  import { discoverAuthStorage, loadCliExtensionProviders } from "../sdk";
29
33
  import { resolveThinkingLevelForModel, shouldDisableReasoning, toReasoningEffort } from "../thinking";
30
34
 
31
- const DEFAULT_RUNS = 1;
35
+ const DEFAULT_RUNS = 10;
36
+ const DEFAULT_PAR = 4;
32
37
  const DEFAULT_MAX_TOKENS = 512;
33
38
  const ERROR_WIDTH = 110;
34
39
  const BENCH_PROMPT = benchPrompt.trim();
@@ -39,7 +44,10 @@ export interface BenchCommandArgs {
39
44
  runs?: number;
40
45
  maxTokens?: number;
41
46
  prompt?: string;
47
+ /** Service-tier setting value (`none` omits); overrides the configured `serviceTier` setting. */
48
+ serviceTier?: string;
42
49
  json?: boolean;
50
+ par?: number;
43
51
  };
44
52
  }
45
53
 
@@ -99,6 +107,8 @@ export interface BenchSummary {
99
107
  maxTokens: number;
100
108
  models: BenchModelReport[];
101
109
  failures: number;
110
+ /** Requested per-family service tiers, resolved per model before reaching the wire. */
111
+ serviceTierByFamily?: ServiceTierByFamily;
102
112
  }
103
113
 
104
114
  type BenchStreamSimple = (
@@ -131,6 +141,13 @@ function normalizePositiveInteger(name: string, value: number | undefined, fallb
131
141
  return value;
132
142
  }
133
143
 
144
+ function closeProviderSessionStates(providerSessionState: Map<string, ProviderSessionState>): void {
145
+ for (const state of providerSessionState.values()) {
146
+ state.close();
147
+ }
148
+ providerSessionState.clear();
149
+ }
150
+
134
151
  function isFirstTokenEvent(event: AssistantMessageEvent): boolean {
135
152
  switch (event.type) {
136
153
  case "text_delta":
@@ -188,6 +205,8 @@ interface BenchRequestOptions {
188
205
  reasoning?: Effort;
189
206
  /** Only set for an explicit `:off` suffix — some endpoints reject disablement. */
190
207
  disableReasoning?: boolean;
208
+ /** Requested service tier passed to `streamSimple`; absent omits the option. The provider layer applies scope/support gating before it reaches the wire. */
209
+ serviceTier?: ServiceTier;
191
210
  }
192
211
 
193
212
  async function runBenchRequest(
@@ -198,6 +217,7 @@ async function runBenchRequest(
198
217
  ): Promise<BenchRunResult> {
199
218
  const startedAt = now();
200
219
  let firstTokenAt: number | undefined;
220
+ const providerSessionState = new Map<string, ProviderSessionState>();
201
221
  try {
202
222
  const context: Context = {
203
223
  // Codex's Responses endpoint 400s with "Instructions are required" when no
@@ -214,6 +234,9 @@ async function runBenchRequest(
214
234
  : options.maxTokens,
215
235
  reasoning: options.reasoning,
216
236
  disableReasoning: options.disableReasoning,
237
+ serviceTier: options.serviceTier,
238
+ providerSessionState,
239
+ preferWebsockets: true,
217
240
  // pi-ai opts every OpenRouter request into response caching (1h TTL).
218
241
  // Bench sends a byte-identical request each run, so within the TTL
219
242
  // OpenRouter replays the cached generation with zeroed usage — the run
@@ -270,6 +293,8 @@ async function runBenchRequest(
270
293
  };
271
294
  } catch (error) {
272
295
  return { ok: false, error: getErrorMessage(error) };
296
+ } finally {
297
+ closeProviderSessionStates(providerSessionState);
273
298
  }
274
299
  }
275
300
 
@@ -469,10 +494,11 @@ function resolveBenchModels(
469
494
  }
470
495
  return resolved;
471
496
  }
472
-
473
497
  export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDependencies = {}): Promise<BenchSummary> {
474
498
  const runs = normalizePositiveInteger("runs", command.flags.runs, DEFAULT_RUNS);
475
499
  const maxTokens = normalizePositiveInteger("max-tokens", command.flags.maxTokens, DEFAULT_MAX_TOKENS);
500
+ const par =
501
+ command.flags.par !== undefined ? normalizePositiveInteger("par", command.flags.par, DEFAULT_PAR) : DEFAULT_PAR;
476
502
  const prompt = command.flags.prompt?.trim() || BENCH_PROMPT;
477
503
  const json = command.flags.json === true;
478
504
  const randomSessionId = deps.randomSessionId ?? (() => Bun.randomUUIDv7());
@@ -493,6 +519,18 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
493
519
  const runtime = await (deps.createRuntime ?? createDefaultRuntime)();
494
520
  try {
495
521
  const targets = resolveBenchModels(command.models, runtime.modelRegistry, runtime.settings, writeStderr);
522
+ // Explicit `--service-tier` (a single value broadcast across families) wins;
523
+ // otherwise fall back to the configured per-family `tier.*` settings. Each
524
+ // model resolves its own family's tier below before reaching the wire.
525
+ const flagTier = command.flags.serviceTier ? serviceTierSettingToTier(command.flags.serviceTier) : undefined;
526
+ const serviceTierByFamily = command.flags.serviceTier
527
+ ? serviceTierForAllFamilies(flagTier)
528
+ : buildServiceTierByFamily(
529
+ runtime.settings?.get("tier.openai") ?? "none",
530
+ runtime.settings?.get("tier.anthropic") ?? "none",
531
+ runtime.settings?.get("tier.google") ?? "none",
532
+ );
533
+ if (!json && flagTier) writeStdout(`${chalk.dim(`service tier: ${flagTier}`)}\n`);
496
534
  const reports: BenchModelReport[] = [];
497
535
  for (const { selector, model, thinking } of targets) {
498
536
  if (!json) {
@@ -501,21 +539,29 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
501
539
  writeStdout(`${chalk.bold(resolvedModel)}${resolvedNote}\n`);
502
540
  }
503
541
  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
- }
542
+
543
+ // Preflight check: let's verify credentials before starting any runs.
544
+ // This matches the old sequential break behavior exactly and avoids launching/printing
545
+ // multiple failures.
546
+ const testSessionId = randomSessionId();
547
+ const preflightKey = await runtime.modelRegistry.getApiKey(model, testSessionId);
548
+ if (!preflightKey) {
549
+ const failure: BenchRunFailure = {
550
+ ok: false,
551
+ error: `No credentials for provider "${model.provider}". Run \`omp\` and use /login, or set the provider API key.`,
552
+ };
553
+ results.push(failure);
554
+ if (!json) writeStdout(`${formatRunLine(failure, 0, runs)}\n`);
555
+ reports.push(buildModelReport(selector, model, thinking, results));
556
+ continue;
557
+ }
558
+
559
+ // We will launch up to `par` workers/requests concurrently.
560
+ // To keep output clean, non-JSON output emits entries in correct index order.
561
+ let nextToPrint = 0;
562
+
563
+ const runWorker = async (index: number) => {
564
+ const sessionId = index === 0 ? testSessionId : randomSessionId();
519
565
  const result = await runBenchRequest(
520
566
  model,
521
567
  {
@@ -525,20 +571,49 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
525
571
  maxTokens,
526
572
  reasoning: toReasoningEffort(thinking),
527
573
  disableReasoning: shouldDisableReasoning(thinking) ? true : undefined,
574
+ serviceTier: resolveModelServiceTier(serviceTierByFamily, model),
528
575
  },
529
576
  streamFn,
530
577
  now,
531
578
  );
532
- results.push(result);
579
+ results[index] = result;
580
+ };
581
+
582
+ // Concurrency-limited running pool
583
+ const queue = Array.from({ length: runs }, (_, i) => i);
584
+ const activeWorkers: Promise<void>[] = [];
585
+
586
+ const processNext = async (): Promise<void> => {
587
+ if (queue.length === 0) return;
588
+ const index = queue.shift()!;
589
+
590
+ // Pre-print a status update if requested and interactive
591
+ if (!json && interactive) {
592
+ writeStdout(chalk.dim(` … run ${index + 1}/${runs} streaming\n`));
593
+ }
594
+
595
+ await runWorker(index);
596
+
597
+ // Attempt to print completed results that are in-order
533
598
  if (!json) {
534
- if (interactive) writeStdout("\r\x1b[2K");
535
- writeStdout(`${formatRunLine(result, index, runs)}\n`);
599
+ while (nextToPrint < runs && results[nextToPrint] !== undefined) {
600
+ const res = results[nextToPrint];
601
+ writeStdout(`${formatRunLine(res, nextToPrint, runs)}\n`);
602
+ nextToPrint++;
603
+ }
536
604
  }
605
+
606
+ await processNext();
607
+ };
608
+
609
+ for (let w = 0; w < Math.min(par, runs); w++) {
610
+ activeWorkers.push(processNext());
537
611
  }
612
+ await Promise.all(activeWorkers);
538
613
  reports.push(buildModelReport(selector, model, thinking, results));
539
614
  }
540
615
  const failures = reports.reduce((sum, report) => sum + report.results.filter(result => !result.ok).length, 0);
541
- const summary: BenchSummary = { runs, maxTokens, models: reports, failures };
616
+ const summary: BenchSummary = { runs, maxTokens, models: reports, failures, serviceTierByFamily };
542
617
  if (json) {
543
618
  writeStdout(`${JSON.stringify(summary, null, 2)}\n`);
544
619
  } else if (reports.length > 1 || runs > 1) {
@@ -28,12 +28,21 @@ interface ProgressReporter {
28
28
  interface DownloadResult {
29
29
  model: TinyLocalModelKey;
30
30
  ok: boolean;
31
+ error?: string;
31
32
  }
32
33
 
33
34
  function writeLine(text = ""): void {
34
35
  process.stdout.write(`${text}\n`);
35
36
  }
36
37
 
38
+ function downloadErrorSummary(error: string | undefined): string | undefined {
39
+ return error
40
+ ?.split(/\r?\n/)
41
+ .map(line => line.trim())
42
+ .find(line => line.length > 0)
43
+ ?.replace(/^Error:\s*/, "");
44
+ }
45
+
37
46
  export function resolveModels(model: string | undefined): TinyLocalModelKey[] {
38
47
  if (!model) return [DEFAULT_TINY_TITLE_LOCAL_MODEL_KEY];
39
48
  // `all` is a prefetch convenience: skip models that fail before load (unsupported
@@ -101,10 +110,15 @@ async function downloadOne(modelKey: TinyLocalModelKey, json: boolean | undefine
101
110
  const label = getTinyLocalModelSpec(modelKey)?.label ?? modelKey;
102
111
  if (!json && !process.stdout.isTTY) writeLine(`Downloading ${label} (${modelKey})...`);
103
112
  const progress = makeProgressReporter(modelKey, json);
104
- const ok = await tinyTitleClient.downloadModel(modelKey, { onProgress: progress.onProgress });
105
- progress.finish(ok);
106
- if (!json && !process.stdout.isTTY) writeLine(ok ? `Downloaded ${label}.` : `Failed to download ${label}.`);
107
- return { model: modelKey, ok };
113
+ const result = await tinyTitleClient.downloadModel(modelKey, { onProgress: progress.onProgress });
114
+ progress.finish(result.ok);
115
+ const error = downloadErrorSummary(result.error);
116
+ if (!json && !process.stdout.isTTY) {
117
+ writeLine(result.ok ? `Downloaded ${label}.` : `Failed to download ${label}${error ? `: ${error}` : ""}.`);
118
+ } else if (!json && !result.ok && error) {
119
+ writeLine(`${label} failed: ${error}`);
120
+ }
121
+ return result.error ? { model: modelKey, ok: result.ok, error: result.error } : { model: modelKey, ok: result.ok };
108
122
  }
109
123
 
110
124
  export async function runTinyModelsCommand(command: TinyModelsCommandArgs): Promise<void> {
@@ -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 = {