@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.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 (95) hide show
  1. package/CHANGELOG.md +146 -198
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  12. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  13. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  14. package/dist/types/main.d.ts +5 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  17. package/dist/types/modes/components/session-selector.d.ts +32 -5
  18. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/interactive-mode.d.ts +9 -2
  22. package/dist/types/modes/types.d.ts +4 -2
  23. package/dist/types/registry/agent-registry.d.ts +1 -1
  24. package/dist/types/sdk.d.ts +2 -2
  25. package/dist/types/session/agent-session.d.ts +4 -2
  26. package/dist/types/session/history-storage.d.ts +16 -1
  27. package/dist/types/session/session-manager.d.ts +4 -0
  28. package/dist/types/task/output-manager.d.ts +6 -15
  29. package/dist/types/tools/find.d.ts +0 -9
  30. package/dist/types/tools/index.d.ts +1 -1
  31. package/dist/types/tools/path-utils.d.ts +16 -0
  32. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  33. package/dist/types/utils/clipboard.d.ts +4 -0
  34. package/dist/types/web/kagi.d.ts +76 -0
  35. package/dist/types/web/search/providers/exa.d.ts +7 -1
  36. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  37. package/package.json +9 -9
  38. package/src/async/job-manager.ts +3 -3
  39. package/src/cli/args.ts +6 -2
  40. package/src/cli/claude-trace-cli.ts +783 -0
  41. package/src/cli/session-picker.ts +36 -10
  42. package/src/cli/update-cli.ts +35 -2
  43. package/src/commands/launch.ts +3 -0
  44. package/src/config/keybindings.ts +6 -0
  45. package/src/config/settings-schema.ts +2 -2
  46. package/src/config/settings.ts +23 -0
  47. package/src/discovery/claude-plugins.ts +7 -9
  48. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  49. package/src/eval/concurrency-bridge.ts +34 -0
  50. package/src/eval/js/shared/prelude.txt +20 -17
  51. package/src/eval/js/tool-bridge.ts +5 -0
  52. package/src/eval/py/prelude.py +23 -15
  53. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  54. package/src/extensibility/skills.ts +0 -1
  55. package/src/internal-urls/docs-index.generated.ts +11 -10
  56. package/src/main.ts +92 -24
  57. package/src/modes/acp/acp-event-mapper.ts +54 -4
  58. package/src/modes/components/custom-editor.ts +10 -0
  59. package/src/modes/components/hook-selector.ts +89 -31
  60. package/src/modes/components/oauth-selector.ts +12 -6
  61. package/src/modes/components/session-selector.ts +179 -24
  62. package/src/modes/components/tool-execution.ts +16 -3
  63. package/src/modes/controllers/command-controller.ts +2 -11
  64. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  65. package/src/modes/controllers/input-controller.ts +19 -1
  66. package/src/modes/controllers/selector-controller.ts +61 -21
  67. package/src/modes/interactive-mode.ts +125 -15
  68. package/src/modes/types.ts +5 -2
  69. package/src/prompts/system/orchestrate-notice.md +5 -3
  70. package/src/prompts/system/workflow-notice.md +2 -2
  71. package/src/prompts/tools/eval.md +5 -5
  72. package/src/prompts/tools/find.md +1 -1
  73. package/src/prompts/tools/irc.md +6 -6
  74. package/src/prompts/tools/search.md +1 -1
  75. package/src/prompts/tools/task.md +1 -1
  76. package/src/registry/agent-registry.ts +1 -1
  77. package/src/sdk.ts +85 -31
  78. package/src/session/agent-session.ts +62 -46
  79. package/src/session/history-storage.ts +56 -12
  80. package/src/session/session-manager.ts +34 -0
  81. package/src/task/output-manager.ts +40 -48
  82. package/src/task/render.ts +3 -8
  83. package/src/tools/browser/tab-worker.ts +8 -5
  84. package/src/tools/find.ts +5 -29
  85. package/src/tools/index.ts +1 -1
  86. package/src/tools/path-utils.ts +144 -1
  87. package/src/tools/read.ts +47 -0
  88. package/src/tools/search.ts +2 -27
  89. package/src/tools/sqlite-reader.ts +92 -9
  90. package/src/utils/clipboard.ts +38 -1
  91. package/src/utils/open.ts +37 -2
  92. package/src/web/kagi.ts +168 -49
  93. package/src/web/search/providers/anthropic.ts +1 -1
  94. package/src/web/search/providers/exa.ts +20 -86
  95. package/src/web/search/providers/kagi.ts +4 -0
@@ -1,23 +1,43 @@
1
1
  import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
2
+ import { logger } from "@oh-my-pi/pi-utils";
2
3
  import { SessionSelectorComponent } from "../modes/components/session-selector";
3
- import type { SessionInfo } from "../session/session-manager";
4
+ import { HistoryStorage } from "../session/history-storage";
5
+ import { type SessionInfo, SessionManager } from "../session/session-manager";
4
6
  import { FileSessionStorage } from "../session/session-storage";
5
7
 
6
- /** Show TUI session selector and return selected session path or null if cancelled */
7
- export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
8
- const { promise, resolve } = Promise.withResolvers<string | null>();
8
+ /**
9
+ * Show the TUI session selector and return the selected session, or null if
10
+ * cancelled. Tab toggles between current-folder and all-projects scope; the
11
+ * all-projects list is loaded lazily via `SessionManager.listAll`.
12
+ */
13
+ export async function selectSession(
14
+ sessions: SessionInfo[],
15
+ options?: { allSessions?: SessionInfo[]; startInAllScope?: boolean },
16
+ ): Promise<SessionInfo | null> {
17
+ const { promise, resolve } = Promise.withResolvers<SessionInfo | null>();
9
18
  const ui = new TUI(new ProcessTerminal());
10
19
  let resolved = false;
11
20
  const storage = new FileSessionStorage();
12
21
 
22
+ // Rank sessions with prompt-history matches too, recovering prompts the 4KB
23
+ // session-list prefix never sees. Best-effort: a missing/locked history.db
24
+ // must not break the picker.
25
+ let historyMatcher: ((query: string) => string[]) | undefined;
26
+ try {
27
+ const history = HistoryStorage.open();
28
+ historyMatcher = (query: string) => history.matchingSessionIds(query);
29
+ } catch (error) {
30
+ logger.warn("History storage unavailable for session ranking", { error: String(error) });
31
+ }
32
+
13
33
  const showSelector = () => {
14
34
  const selector = new SessionSelectorComponent(
15
35
  sessions,
16
- (path: string) => {
36
+ (session: SessionInfo) => {
17
37
  if (!resolved) {
18
38
  resolved = true;
19
39
  ui.stop();
20
- resolve(path);
40
+ resolve(session);
21
41
  }
22
42
  },
23
43
  () => {
@@ -34,10 +54,16 @@ export async function selectSession(sessions: SessionInfo[]): Promise<string | n
34
54
  process.exit(0);
35
55
  }
36
56
  },
37
- async (session: SessionInfo) => {
38
- // Delete handler - SessionList will show confirmation internally
39
- await storage.deleteSessionWithArtifacts(session.path);
40
- return true;
57
+ {
58
+ onDelete: async (session: SessionInfo) => {
59
+ // Delete handler - SessionList will show confirmation internally
60
+ await storage.deleteSessionWithArtifacts(session.path);
61
+ return true;
62
+ },
63
+ historyMatcher,
64
+ loadAllSessions: () => SessionManager.listAll(storage),
65
+ allSessions: options?.allSessions,
66
+ startInAllScope: options?.startInAllScope,
41
67
  },
42
68
  );
43
69
  return selector;
@@ -14,6 +14,18 @@ import { theme } from "../modes/theme/theme";
14
14
 
15
15
  const REPO = "can1357/oh-my-pi";
16
16
  const PACKAGE = "@oh-my-pi/pi-coding-agent";
17
+ /**
18
+ * Official npm registry origin.
19
+ *
20
+ * Pinned across both the version check and the bun install step so the two
21
+ * agree on which catalog they are talking to. A user's bun may be pointed at
22
+ * an unofficial mirror (corporate proxy, Taobao, etc.) that lags the upstream
23
+ * registry by minutes-to-hours, in which case `getLatestRelease` would resolve
24
+ * a version the mirror has not yet replicated and the install would fail with
25
+ * `No version matching "X" found for specifier "<pkg>" (but package exists)`.
26
+ * See #1686.
27
+ */
28
+ const NPM_REGISTRY = "https://registry.npmjs.org/";
17
29
 
18
30
  interface ReleaseInfo {
19
31
  tag: string;
@@ -130,7 +142,7 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
130
142
  * Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
131
143
  */
132
144
  async function getLatestRelease(): Promise<ReleaseInfo> {
133
- const response = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`);
145
+ const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`);
134
146
  if (!response.ok) {
135
147
  throw new Error(`Failed to fetch release info: ${response.statusText}`);
136
148
  }
@@ -292,12 +304,33 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
292
304
  }
293
305
  }
294
306
 
307
+ /**
308
+ * Build the bun argv used to globally install a specific omp version.
309
+ *
310
+ * The version is selected by hitting {@link NPM_REGISTRY} directly in
311
+ * {@link getLatestRelease}, so the install MUST observe the same catalog:
312
+ *
313
+ * - `--registry=${NPM_REGISTRY}` pins the install to the official registry
314
+ * regardless of the user's bunfig/`.npmrc`. A mirror (corporate proxy,
315
+ * Taobao, …) that hasn't yet replicated the release would otherwise reject
316
+ * a version the upstream registry already advertises.
317
+ * - `--no-cache` tells bun to ignore its on-disk manifest snapshot so it
318
+ * re-fetches metadata from that registry on every invocation.
319
+ *
320
+ * Together these two flags make `omp update` produce exactly the registry
321
+ * lookup the version check just performed. See #1686.
322
+ */
323
+ export function buildBunInstallArgs(expectedVersion: string): string[] {
324
+ return ["install", "-g", "--no-cache", `--registry=${NPM_REGISTRY}`, `${PACKAGE}@${expectedVersion}`];
325
+ }
326
+
295
327
  /**
296
328
  * Update via bun package manager.
297
329
  */
298
330
  async function updateViaBun(expectedVersion: string): Promise<void> {
299
331
  console.log(chalk.dim("Updating via bun..."));
300
- const result = await $`bun install -g ${PACKAGE}@${expectedVersion}`.nothrow();
332
+ const args = buildBunInstallArgs(expectedVersion);
333
+ const result = await $`bun ${args}`.nothrow();
301
334
  if (result.exitCode !== 0) {
302
335
  throw new Error(`bun install failed with exit code ${result.exitCode}`);
303
336
  }
@@ -90,6 +90,9 @@ export default class Index extends Command {
90
90
  description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
91
91
  options: [...THINKING_EFFORTS],
92
92
  }),
93
+ "hide-thinking": Flags.boolean({
94
+ description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
95
+ }),
93
96
  hook: Flags.string({
94
97
  description: "Load a hook/extension file (can be used multiple times)",
95
98
  multiple: true,
@@ -32,6 +32,7 @@ interface AppKeybindings {
32
32
  "app.message.followUp": true;
33
33
  "app.message.dequeue": true;
34
34
  "app.clipboard.pasteImage": true;
35
+ "app.clipboard.pasteTextRaw": true;
35
36
  "app.clipboard.copyLine": true;
36
37
  "app.clipboard.copyPrompt": true;
37
38
  "app.session.new": true;
@@ -122,6 +123,10 @@ export const KEYBINDINGS = {
122
123
  defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
123
124
  description: "Paste image from clipboard",
124
125
  },
126
+ "app.clipboard.pasteTextRaw": {
127
+ defaultKeys: ["ctrl+shift+v", "alt+shift+v"],
128
+ description: "Paste text from clipboard as raw text (no collapse)",
129
+ },
125
130
  "app.clipboard.copyLine": {
126
131
  defaultKeys: "alt+shift+l",
127
132
  description: "Copy current line",
@@ -214,6 +219,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
214
219
  followUp: "app.message.followUp",
215
220
  dequeue: "app.message.dequeue",
216
221
  pasteImage: "app.clipboard.pasteImage",
222
+ pasteTextRaw: "app.clipboard.pasteTextRaw",
217
223
  copyLine: "app.clipboard.copyLine",
218
224
  copyPrompt: "app.clipboard.copyPrompt",
219
225
  newSession: "app.session.new",
@@ -2894,7 +2894,7 @@ export const SETTINGS_SCHEMA = {
2894
2894
  label: "Auto",
2895
2895
  description: "Preferred web-search provider",
2896
2896
  },
2897
- { value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" },
2897
+ { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
2898
2898
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
2899
2899
  { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
2900
2900
  { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
@@ -2920,7 +2920,7 @@ export const SETTINGS_SCHEMA = {
2920
2920
  },
2921
2921
  { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
2922
2922
  { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" },
2923
- { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
2923
+ { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY (Kagi V1 Search API)" },
2924
2924
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
2925
2925
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
2926
2926
  { value: "searxng", label: "SearXNG", description: "Requires SEARXNG_ENDPOINT or searxng.endpoint" },
@@ -365,6 +365,29 @@ export class Settings {
365
365
  return cloned;
366
366
  }
367
367
 
368
+ /**
369
+ * Re-scope this instance to a new working directory *in place*: reload the
370
+ * project layer (`.claude/settings.yml` etc.) from `cwd`, re-resolve
371
+ * path-scoped settings against it, and re-fire side-effect hooks (theme,
372
+ * symbols, tab width, …). Global settings and runtime overrides are preserved.
373
+ *
374
+ * Unlike {@link cloneForCwd}, this mutates the live instance, so every holder
375
+ * (the `settings` proxy, the active session, controllers) observes the new
376
+ * project scope without swapping references — used when the process changes
377
+ * directory mid-run (`/move`, cross-project resume). No-op when `cwd` is
378
+ * already the current scope.
379
+ */
380
+ async reloadForCwd(cwd: string): Promise<void> {
381
+ const normalized = path.normalize(cwd);
382
+ if (normalized === this.#cwd) return;
383
+ this.#cwd = normalized;
384
+ if (this.#persist) {
385
+ this.#project = await this.#loadProjectSettings();
386
+ }
387
+ this.#rebuildMerged();
388
+ this.#fireAllHooks();
389
+ }
390
+
368
391
  // ─────────────────────────────────────────────────────────────────────────
369
392
  // Accessors
370
393
  // ─────────────────────────────────────────────────────────────────────────
@@ -99,10 +99,8 @@ async function resolvePluginDir(
99
99
  async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
100
100
  const items: Skill[] = [];
101
101
  const warnings: string[] = [];
102
-
103
102
  const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
104
103
  warnings.push(...rootWarnings);
105
-
106
104
  const results = await Promise.all(
107
105
  roots.map(async root => {
108
106
  const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
@@ -114,16 +112,16 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
114
112
  return { root, result, warning };
115
113
  }),
116
114
  );
117
-
118
- for (const { root, result, warning } of results) {
115
+ for (const { result, warning } of results) {
119
116
  if (warning) warnings.push(warning);
120
- for (const skill of result.items) {
121
- if (root.plugin) skill.name = `${root.plugin}:${skill.name}`;
122
- items.push(skill);
123
- }
117
+ // Intentionally do NOT prefix skill names with `root.plugin`.
118
+ // The `plugin:name` format breaks skill:// URL parsing (colons are
119
+ // ambiguous with port separators) and is unintuitive for callers.
120
+ // Dedup-by-key in the capability layer already handles name collisions
121
+ // across providers using priority ordering.
122
+ items.push(...result.items);
124
123
  if (result.warnings) warnings.push(...result.warnings);
125
124
  }
126
-
127
125
  return { items, warnings };
128
126
  }
129
127
 
@@ -106,6 +106,7 @@ function singleResult(options: ExecutorOptions, overrides: Partial<SingleResult>
106
106
  function makeEvalSession(
107
107
  tempDir: TempDir,
108
108
  prefix: string,
109
+ settings?: Settings,
109
110
  ): { session: ToolSession; sessionFile: string; sessionId: string } {
110
111
  const sessionFile = path.join(tempDir.path(), "session.jsonl");
111
112
  const artifactsDir = sessionFile.slice(0, -6);
@@ -113,6 +114,7 @@ function makeEvalSession(
113
114
  cwd: tempDir.path(),
114
115
  sessionFile,
115
116
  artifactsDir,
117
+ settings,
116
118
  outputManager: new AgentOutputManager(() => artifactsDir),
117
119
  });
118
120
  return { session, sessionFile, sessionId: `${prefix}:${crypto.randomUUID()}` };
@@ -261,9 +263,15 @@ describe("agent() through eval runtimes", () => {
261
263
  expect(JSON.parse(result.output.trim())).toEqual(["hello from agent", { ok: true, n: 3 }]);
262
264
  });
263
265
 
264
- it("runs JavaScript parallel() with bounded concurrency while preserving order", async () => {
266
+ it("bounds JavaScript parallel() by the task.maxConcurrency setting while preserving order", async () => {
265
267
  using tempDir = TempDir.createSync("@omp-eval-agent-js-parallel-");
266
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel");
268
+ const settings = Settings.isolated({
269
+ "async.enabled": false,
270
+ "task.isolation.mode": "none",
271
+ "task.enableLsp": true,
272
+ "task.maxConcurrency": 2,
273
+ });
274
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel", settings);
267
275
  mockAgents();
268
276
  let inFlight = 0;
269
277
  let maxInFlight = 0;
@@ -279,7 +287,7 @@ describe("agent() through eval runtimes", () => {
279
287
  });
280
288
 
281
289
  const result = await executeJs(
282
- 'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name)), { concurrency: 2 }); return JSON.stringify(values);',
290
+ 'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name))); return JSON.stringify(values);',
283
291
  { cwd: tempDir.path(), sessionId, session, sessionFile },
284
292
  );
285
293
 
@@ -300,7 +308,7 @@ describe("agent() through eval runtimes", () => {
300
308
  return singleResult(options, { output: options.assignment ?? "" });
301
309
  });
302
310
 
303
- const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")], { concurrency: 2 });', {
311
+ const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")]);', {
304
312
  cwd: tempDir.path(),
305
313
  sessionId,
306
314
  session,
@@ -343,6 +351,52 @@ describe("agent() through eval runtimes", () => {
343
351
  expect(result.output.trim()).toBe("hello from python");
344
352
  });
345
353
 
354
+ it("bounds Python parallel() by the task.maxConcurrency setting while preserving order", async () => {
355
+ using tempDir = TempDir.createSync("@omp-eval-agent-py-parallel-");
356
+ const settings = Settings.isolated({
357
+ "async.enabled": false,
358
+ "task.isolation.mode": "none",
359
+ "task.enableLsp": true,
360
+ "task.maxConcurrency": 2,
361
+ });
362
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-parallel", settings);
363
+ mockAgents();
364
+ let inFlight = 0;
365
+ let maxInFlight = 0;
366
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
367
+ inFlight++;
368
+ maxInFlight = Math.max(maxInFlight, inFlight);
369
+ try {
370
+ await Bun.sleep(options.assignment === "a" ? 30 : 10);
371
+ return singleResult(options, { output: options.assignment ?? "" });
372
+ } finally {
373
+ inFlight--;
374
+ }
375
+ });
376
+
377
+ const probe = await executePython('print("probe")', {
378
+ cwd: tempDir.path(),
379
+ sessionId: `${sessionId}:probe`,
380
+ sessionFile,
381
+ kernelMode: "per-call",
382
+ });
383
+ if (probe.exitCode === undefined && probe.cancelled) {
384
+ expect(probe.output).toBe("");
385
+ return;
386
+ }
387
+ expect(probe.exitCode).toBe(0);
388
+
389
+ const result = await executePython(
390
+ 'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))',
391
+ { cwd: tempDir.path(), sessionId, sessionFile, kernelMode: "per-call", toolSession: session },
392
+ );
393
+
394
+ expect(result.exitCode).toBe(0);
395
+ expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
396
+ expect(maxInFlight).toBeGreaterThan(1);
397
+ expect(maxInFlight).toBeLessThanOrEqual(2);
398
+ });
399
+
346
400
  it("streams enriched agent progress through onStatus before the cell finishes", async () => {
347
401
  using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
348
402
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Host-side handler for the eval `parallel()` / `pipeline()` worker pool.
3
+ *
4
+ * The pool ceiling is not a kernel-side knob: it tracks the `task.maxConcurrency`
5
+ * setting so an eval fan-out runs as wide as a `task` tool batch would. `0` means
6
+ * unbounded — run every item at once, exactly like `task.maxConcurrency = 0`.
7
+ */
8
+ import type { ToolSession } from "../tools";
9
+ import type { JsStatusEvent } from "./js/shared/types";
10
+
11
+ /** Synthetic bridge name reserved for the parallel-pool ceiling across both runtimes. */
12
+ export const EVAL_CONCURRENCY_BRIDGE_NAME = "__concurrency__";
13
+
14
+ export interface EvalConcurrencyBridgeOptions {
15
+ session: ToolSession;
16
+ signal?: AbortSignal;
17
+ emitStatus?: (event: JsStatusEvent) => void;
18
+ }
19
+
20
+ export interface EvalConcurrencyResult {
21
+ /** Worker-pool ceiling; `0` means unbounded (run every item at once). */
22
+ limit: number;
23
+ }
24
+
25
+ /**
26
+ * Resolve the worker-pool ceiling for an eval cell's `parallel()`/`pipeline()`
27
+ * helpers from the live `task.maxConcurrency` setting. Negative/non-finite
28
+ * values collapse to `0` (unbounded), matching the `task` tool's own handling.
29
+ */
30
+ export function runEvalConcurrency(_args: unknown, options: EvalConcurrencyBridgeOptions): EvalConcurrencyResult {
31
+ const raw = options.session.settings.get("task.maxConcurrency");
32
+ const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
33
+ return { limit: limit > 0 ? limit : 0 };
34
+ }
@@ -55,15 +55,24 @@ if (!globalThis.__omp_js_prelude_loaded__) {
55
55
  return hasOwn(o, "schema") ? JSON.parse(text) : text;
56
56
  };
57
57
 
58
- const normalizeConcurrency = value => {
59
- const number = Number(value ?? 4);
60
- if (!Number.isFinite(number)) return 4;
61
- return Math.max(1, Math.min(16, Math.trunc(number)));
58
+ // Pool ceiling mirrors the task tool's `task.maxConcurrency` setting so an
59
+ // eval fan-out runs as wide as a `task` batch would. 0 (and any non-finite
60
+ // reply) means unbounded — run every item at once.
61
+ const __concurrencyLimit = async () => {
62
+ try {
63
+ const r = await globalThis.__omp_call_tool__("__concurrency__", {});
64
+ const n = Math.trunc(Number(r && typeof r === "object" ? r.limit : r));
65
+ return Number.isFinite(n) && n > 0 ? n : 0;
66
+ } catch {
67
+ return 0;
68
+ }
62
69
  };
63
70
 
64
- const __pool = async (items, limit, fn) => {
71
+ const __pool = async (items, fn) => {
65
72
  const list = Array.from(items ?? []);
66
- const concurrency = Math.min(normalizeConcurrency(limit), list.length);
73
+ if (list.length === 0) return [];
74
+ const limit = await __concurrencyLimit();
75
+ const concurrency = limit > 0 ? Math.min(limit, list.length) : list.length;
67
76
  const results = new Array(list.length);
68
77
  let next = 0;
69
78
  const worker = async () => {
@@ -77,23 +86,17 @@ if (!globalThis.__omp_js_prelude_loaded__) {
77
86
  return results;
78
87
  };
79
88
 
80
- const parallel = async (thunks, opts = {}) =>
81
- __pool(thunks, toOptions(opts).concurrency, (thunk, index) => {
89
+ const parallel = async thunks =>
90
+ __pool(thunks, (thunk, index) => {
82
91
  if (typeof thunk !== "function") throw new TypeError("parallel() expects an iterable of functions");
83
92
  return thunk(index);
84
93
  });
85
94
 
86
- const pipeline = async (items, ...stagesAndOptions) => {
87
- let opts = {};
88
- const last = stagesAndOptions.at(-1);
89
- if (last && typeof last === "object" && !Array.isArray(last)) {
90
- opts = last;
91
- stagesAndOptions = stagesAndOptions.slice(0, -1);
92
- }
95
+ const pipeline = async (items, ...stages) => {
93
96
  let current = Array.from(items ?? []);
94
- for (const stage of stagesAndOptions) {
97
+ for (const stage of stages) {
95
98
  if (typeof stage !== "function") throw new TypeError("pipeline() stages must be functions");
96
- current = await __pool(current, toOptions(opts).concurrency, stage);
99
+ current = await __pool(current, stage);
97
100
  }
98
101
  return current;
99
102
  };
@@ -3,6 +3,7 @@ import type { ToolSession } from "../../tools";
3
3
  import { ToolError } from "../../tools/tool-errors";
4
4
  import { EVAL_AGENT_BRIDGE_NAME, runEvalAgent } from "../agent-bridge";
5
5
  import { EVAL_BUDGET_BRIDGE_NAME, type EvalBudgetResult, runEvalBudget } from "../budget-bridge";
6
+ import { EVAL_CONCURRENCY_BRIDGE_NAME, type EvalConcurrencyResult, runEvalConcurrency } from "../concurrency-bridge";
6
7
  import { EVAL_LLM_BRIDGE_NAME, runEvalLlm } from "../llm-bridge";
7
8
  import type { JsStatusEvent } from "./shared/types";
8
9
 
@@ -17,6 +18,7 @@ interface ToolBridgeOptions {
17
18
  type ToolValue =
18
19
  | string
19
20
  | EvalBudgetResult
21
+ | EvalConcurrencyResult
20
22
  | {
21
23
  text: string;
22
24
  details?: unknown;
@@ -114,6 +116,9 @@ export async function callSessionTool(name: string, args: unknown, options: Tool
114
116
  if (name === EVAL_BUDGET_BRIDGE_NAME) {
115
117
  return await runEvalBudget(args, options);
116
118
  }
119
+ if (name === EVAL_CONCURRENCY_BRIDGE_NAME) {
120
+ return runEvalConcurrency(args, options);
121
+ }
117
122
  const tool = getTool(options.session, name);
118
123
  const normalizedArgs = normalizeArgs(args);
119
124
  const toolCallId = `js-${name}-${crypto.randomUUID()}`;
@@ -503,27 +503,35 @@ if "__omp_prelude_loaded__" not in globals():
503
503
  text = res.get("text") if isinstance(res, dict) else res
504
504
  return json.loads(text) if schema is not None else text
505
505
 
506
- def _normalize_concurrency(value):
507
- """Clamp a concurrency hint to [1, 16], defaulting to 4 on bad input."""
506
+ def _concurrency_limit():
507
+ """Worker-pool ceiling from the host ``task.maxConcurrency`` setting.
508
+
509
+ An eval fan-out runs as wide as a ``task`` batch would. Returns ``0`` for
510
+ unbounded (run every item at once); falls back to ``0`` if the host
511
+ bridge is unreachable.
512
+ """
508
513
  try:
509
- n = int(value)
510
- except (TypeError, ValueError):
511
- n = 4
512
- return max(1, min(16, n))
514
+ snap = _bridge_call("__concurrency__", {}) or {}
515
+ n = int(snap.get("limit") or 0)
516
+ except Exception:
517
+ return 0
518
+ return n if n > 0 else 0
513
519
 
514
- def _pool_map(items, fn, concurrency):
520
+ def _pool_map(items, fn):
515
521
  """Run ``fn`` over ``items`` through a bounded thread pool.
516
522
 
517
523
  Preserves input order, barriers until every task settles, and raises the
518
524
  lowest-index exception if any task failed. Each task runs inside a copy
519
525
  of the submitting thread's context so the ``_CURRENT_RID`` ContextVar
520
- propagates and bridge calls (agent(), tool.*, etc.) keep working.
526
+ propagates and bridge calls (agent(), tool.*, etc.) keep working. The
527
+ pool width tracks ``task.maxConcurrency`` (0 = run every item at once).
521
528
  """
522
529
  import concurrent.futures, contextvars
523
530
  items = list(items)
524
531
  if not items:
525
532
  return []
526
- workers = min(_normalize_concurrency(concurrency), len(items))
533
+ limit = _concurrency_limit()
534
+ workers = min(limit, len(items)) if limit > 0 else len(items)
527
535
  results = [None] * len(items)
528
536
  errors = {}
529
537
  with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
@@ -541,30 +549,30 @@ if "__omp_prelude_loaded__" not in globals():
541
549
  raise errors[min(errors)]
542
550
  return results
543
551
 
544
- def parallel(thunks, *, concurrency=4):
552
+ def parallel(thunks):
545
553
  """Run zero-arg callables through a bounded pool, preserving input order.
546
554
 
547
555
  Barriers until all finish; re-raises the lowest-index exception if any
548
- thunk raised.
556
+ thunk raised. Pool width tracks the task tool's ``task.maxConcurrency``.
549
557
  """
550
558
  thunks = list(thunks)
551
559
  for t in thunks:
552
560
  if not callable(t):
553
561
  raise TypeError("parallel() expects an iterable of zero-arg callables")
554
- return _pool_map(thunks, lambda t: t(), concurrency)
562
+ return _pool_map(thunks, lambda t: t())
555
563
 
556
- def pipeline(items, *stages, concurrency=4):
564
+ def pipeline(items, *stages):
557
565
  """Map items left-to-right through one-arg stage callables.
558
566
 
559
567
  Every item clears stage N before any item enters stage N+1 (barrier per
560
568
  stage). Stage 1 receives the original item; later stages receive the
561
- previous stage's result.
569
+ previous stage's result. Pool width tracks ``task.maxConcurrency``.
562
570
  """
563
571
  current = list(items)
564
572
  for stage in stages:
565
573
  if not callable(stage):
566
574
  raise TypeError("pipeline() stages must be callables")
567
- current = _pool_map(current, stage, concurrency)
575
+ current = _pool_map(current, stage)
568
576
  return current
569
577
 
570
578
  def log(message):