@oh-my-pi/pi-coding-agent 14.5.11 → 14.5.13

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 (89) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/config/model-equivalence.ts +49 -16
  5. package/src/config/model-registry.ts +100 -25
  6. package/src/config/model-resolver.ts +29 -15
  7. package/src/config/settings-schema.ts +20 -6
  8. package/src/config/settings.ts +9 -8
  9. package/src/config.ts +9 -0
  10. package/src/eval/backend.ts +43 -0
  11. package/src/eval/eval.lark +43 -0
  12. package/src/eval/index.ts +5 -0
  13. package/src/eval/js/context-manager.ts +717 -0
  14. package/src/eval/js/executor.ts +131 -0
  15. package/src/eval/js/index.ts +46 -0
  16. package/src/eval/js/prelude.ts +2 -0
  17. package/src/eval/js/prelude.txt +84 -0
  18. package/src/eval/js/tool-bridge.ts +124 -0
  19. package/src/eval/parse.ts +337 -0
  20. package/src/{ipy → eval/py}/executor.ts +2 -180
  21. package/src/{ipy → eval/py}/gateway-coordinator.ts +4 -3
  22. package/src/eval/py/index.ts +58 -0
  23. package/src/{ipy → eval/py}/kernel.ts +5 -41
  24. package/src/{ipy → eval/py}/prelude.py +39 -227
  25. package/src/eval/types.ts +48 -0
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.js +23 -17
  28. package/src/extensibility/extensions/types.ts +2 -3
  29. package/src/internal-urls/docs-index.generated.ts +5 -5
  30. package/src/lsp/client.ts +9 -0
  31. package/src/lsp/index.ts +395 -0
  32. package/src/lsp/types.ts +15 -4
  33. package/src/main.ts +25 -14
  34. package/src/mcp/oauth-flow.ts +1 -1
  35. package/src/memories/index.ts +1 -1
  36. package/src/modes/acp/acp-event-mapper.ts +1 -1
  37. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  38. package/src/modes/components/login-dialog.ts +1 -1
  39. package/src/modes/components/oauth-selector.ts +2 -1
  40. package/src/modes/components/tool-execution.ts +3 -4
  41. package/src/modes/controllers/command-controller.ts +28 -8
  42. package/src/modes/controllers/input-controller.ts +4 -4
  43. package/src/modes/controllers/selector-controller.ts +2 -1
  44. package/src/modes/interactive-mode.ts +4 -5
  45. package/src/modes/types.ts +3 -3
  46. package/src/modes/utils/ui-helpers.ts +2 -2
  47. package/src/prompts/system/system-prompt.md +3 -3
  48. package/src/prompts/tools/atom.md +3 -2
  49. package/src/prompts/tools/browser.md +61 -16
  50. package/src/prompts/tools/eval.md +92 -0
  51. package/src/prompts/tools/lsp.md +7 -3
  52. package/src/sdk.ts +45 -31
  53. package/src/session/agent-session.ts +44 -54
  54. package/src/session/messages.ts +1 -1
  55. package/src/slash-commands/builtin-registry.ts +1 -1
  56. package/src/system-prompt.ts +34 -66
  57. package/src/task/executor.ts +5 -9
  58. package/src/tools/browser/attach.ts +175 -0
  59. package/src/tools/browser/launch.ts +576 -0
  60. package/src/tools/browser/readable.ts +90 -0
  61. package/src/tools/browser/registry.ts +198 -0
  62. package/src/tools/browser/render.ts +212 -0
  63. package/src/tools/browser/tab-protocol.ts +101 -0
  64. package/src/tools/browser/tab-supervisor.ts +429 -0
  65. package/src/tools/browser/tab-worker-entry.ts +21 -0
  66. package/src/tools/browser/tab-worker.ts +1006 -0
  67. package/src/tools/browser.ts +231 -1567
  68. package/src/tools/checkpoint.ts +2 -2
  69. package/src/tools/{python.ts → eval.ts} +324 -315
  70. package/src/tools/exit-plan-mode.ts +1 -1
  71. package/src/tools/index.ts +62 -100
  72. package/src/tools/plan-mode-guard.ts +27 -1
  73. package/src/tools/read.ts +0 -6
  74. package/src/tools/recipe/runners/pkg.ts +34 -32
  75. package/src/tools/renderers.ts +4 -2
  76. package/src/tools/resolve.ts +7 -2
  77. package/src/tools/todo-write.ts +0 -1
  78. package/src/tools/tool-timeouts.ts +2 -2
  79. package/src/utils/markit.ts +15 -7
  80. package/src/utils/tools-manager.ts +5 -5
  81. package/src/web/search/index.ts +5 -5
  82. package/src/web/search/provider.ts +121 -39
  83. package/src/web/search/providers/gemini.ts +2 -2
  84. package/src/web/search/render.ts +2 -2
  85. package/src/ipy/modules.ts +0 -144
  86. package/src/prompts/tools/python.md +0 -57
  87. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  88. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  89. /package/src/{ipy → eval/py}/runtime.ts +0 -0
package/src/lsp/client.ts CHANGED
@@ -153,6 +153,15 @@ const CLIENT_CAPABILITIES = {
153
153
  valueSet: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26],
154
154
  },
155
155
  },
156
+ fileOperations: {
157
+ dynamicRegistration: false,
158
+ willCreate: false,
159
+ didCreate: false,
160
+ willRename: true,
161
+ didRename: true,
162
+ willDelete: false,
163
+ didDelete: false,
164
+ },
156
165
  },
157
166
  experimental: {
158
167
  snippetTextEdit: true,
package/src/lsp/index.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type LspServerStatus,
17
17
  notifySaved,
18
18
  refreshFile,
19
+ sendNotification,
19
20
  sendRequest,
20
21
  setIdleTimeout,
21
22
  syncContent,
@@ -325,6 +326,61 @@ async function formatLocationWithContext(location: Location, cwd: string): Promi
325
326
  }
326
327
  return `${header}\n${context.map(lineText => ` ${lineText}`).join("\n")}`;
327
328
  }
329
+
330
+ const MAX_RENAME_PAIRS = 1000;
331
+
332
+ interface FileRenamePair {
333
+ oldUri: string;
334
+ newUri: string;
335
+ }
336
+
337
+ /**
338
+ * Enumerate the {oldUri, newUri} pairs needed for an LSP willRenameFiles/didRenameFiles request.
339
+ * For files this is a single pair. For directories this walks every regular file underneath
340
+ * and produces a parallel pair anchored at the new directory root.
341
+ */
342
+ async function enumerateRenamePairs(
343
+ source: string,
344
+ dest: string,
345
+ ): Promise<{ pairs: FileRenamePair[]; directory: boolean; exceeded: boolean }> {
346
+ const stat = await fs.promises.stat(source);
347
+ if (!stat.isDirectory()) {
348
+ return {
349
+ pairs: [{ oldUri: fileToUri(source), newUri: fileToUri(dest) }],
350
+ directory: false,
351
+ exceeded: false,
352
+ };
353
+ }
354
+ const entries = await fs.promises.readdir(source, { recursive: true, withFileTypes: true });
355
+ const pairs: FileRenamePair[] = [];
356
+ for (const entry of entries) {
357
+ if (!entry.isFile()) continue;
358
+ if (pairs.length >= MAX_RENAME_PAIRS) {
359
+ return { pairs, directory: true, exceeded: true };
360
+ }
361
+ const parent = entry.parentPath ?? source;
362
+ const absOld = path.join(parent, entry.name);
363
+ const rel = path.relative(source, absOld);
364
+ pairs.push({
365
+ oldUri: fileToUri(absOld),
366
+ newUri: fileToUri(path.join(dest, rel)),
367
+ });
368
+ }
369
+ return { pairs, directory: true, exceeded: false };
370
+ }
371
+
372
+ /** True when an LSP error indicates the server doesn't implement the requested method. */
373
+ function isMethodNotFoundError(err: unknown): boolean {
374
+ if (!(err instanceof Error)) return false;
375
+ const msg = err.message.toLowerCase();
376
+ return (
377
+ msg.includes("method not found") ||
378
+ msg.includes("unhandled method") ||
379
+ msg.includes("not supported") ||
380
+ msg.includes("-32601")
381
+ );
382
+ }
383
+
328
384
  async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
329
385
  let output = `Restarted ${serverName}`;
330
386
  const reloadMethods = ["rust-analyzer/reloadWorkspace", "workspace/didChangeConfiguration"];
@@ -1312,6 +1368,345 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1312
1368
  };
1313
1369
  }
1314
1370
 
1371
+ if (action === "rename_file") {
1372
+ if (!file || !new_name) {
1373
+ return {
1374
+ content: [
1375
+ {
1376
+ type: "text",
1377
+ text: "Error: rename_file requires both `file` (source path) and `new_name` (destination path)",
1378
+ },
1379
+ ],
1380
+ details: { action, success: false, request: params },
1381
+ };
1382
+ }
1383
+
1384
+ const source = resolveToCwd(file, this.session.cwd);
1385
+ const dest = resolveToCwd(new_name, this.session.cwd);
1386
+
1387
+ if (source === dest) {
1388
+ return {
1389
+ content: [{ type: "text", text: "Error: source and destination paths are identical" }],
1390
+ details: { action, success: false, request: params },
1391
+ };
1392
+ }
1393
+
1394
+ let sourceStat: fs.Stats;
1395
+ try {
1396
+ sourceStat = await fs.promises.stat(source);
1397
+ } catch {
1398
+ return {
1399
+ content: [
1400
+ {
1401
+ type: "text",
1402
+ text: `Error: source path does not exist: ${formatPathRelativeToCwd(source, this.session.cwd)}`,
1403
+ },
1404
+ ],
1405
+ details: { action, success: false, request: params },
1406
+ };
1407
+ }
1408
+
1409
+ let destExists = false;
1410
+ try {
1411
+ await fs.promises.stat(dest);
1412
+ destExists = true;
1413
+ } catch {
1414
+ // expected: destination must not exist
1415
+ }
1416
+ if (destExists) {
1417
+ return {
1418
+ content: [
1419
+ {
1420
+ type: "text",
1421
+ text: `Error: destination already exists: ${formatPathRelativeToCwd(dest, this.session.cwd)}`,
1422
+ },
1423
+ ],
1424
+ details: { action, success: false, request: params },
1425
+ };
1426
+ }
1427
+
1428
+ const enumerated = await enumerateRenamePairs(source, dest);
1429
+ if (enumerated.exceeded) {
1430
+ return {
1431
+ content: [
1432
+ {
1433
+ type: "text",
1434
+ text: `Error: directory contains more than ${MAX_RENAME_PAIRS} files; rename in smaller batches to keep LSP edits accurate`,
1435
+ },
1436
+ ],
1437
+ details: { action, success: false, request: params },
1438
+ };
1439
+ }
1440
+ const { pairs } = enumerated;
1441
+ if (pairs.length === 0) {
1442
+ return {
1443
+ content: [{ type: "text", text: "Error: no files to rename" }],
1444
+ details: { action, success: false, request: params },
1445
+ };
1446
+ }
1447
+
1448
+ const lspParams = { files: pairs };
1449
+ const servers = getLspServers(config);
1450
+ const respondingServers = new Set<string>();
1451
+ const perServerEdits: Array<{ serverName: string; edit: WorkspaceEdit }> = [];
1452
+ const serverNotes: string[] = [];
1453
+
1454
+ for (const [serverName, serverConfig] of servers) {
1455
+ throwIfAborted(signal);
1456
+ try {
1457
+ const client = await getOrCreateClient(serverConfig, this.session.cwd);
1458
+ if (isProjectAwareLspServer(serverConfig)) {
1459
+ await waitForProjectLoaded(client, signal);
1460
+ }
1461
+ const result = (await sendRequest(
1462
+ client,
1463
+ "workspace/willRenameFiles",
1464
+ lspParams,
1465
+ signal,
1466
+ )) as WorkspaceEdit | null;
1467
+ respondingServers.add(serverName);
1468
+ if (result && (result.changes || result.documentChanges)) {
1469
+ perServerEdits.push({ serverName, edit: result });
1470
+ }
1471
+ } catch (err) {
1472
+ if (err instanceof ToolAbortError || signal?.aborted) {
1473
+ throw err;
1474
+ }
1475
+ if (!isMethodNotFoundError(err)) {
1476
+ const msg = err instanceof Error ? err.message : String(err);
1477
+ serverNotes.push(` ${serverName}: ${msg}`);
1478
+ }
1479
+ }
1480
+ }
1481
+
1482
+ const sourceLabel = formatPathRelativeToCwd(source, this.session.cwd);
1483
+ const destLabel = formatPathRelativeToCwd(dest, this.session.cwd);
1484
+ const fileCountLabel = sourceStat.isDirectory()
1485
+ ? `${pairs.length} file${pairs.length !== 1 ? "s" : ""} under ${sourceLabel}`
1486
+ : sourceLabel;
1487
+
1488
+ const shouldApply = apply !== false;
1489
+ if (!shouldApply) {
1490
+ const lines: string[] = [];
1491
+ lines.push(`Rename preview: ${fileCountLabel} → ${destLabel}`);
1492
+ if (perServerEdits.length === 0) {
1493
+ lines.push(" No LSP edits would be applied");
1494
+ } else {
1495
+ for (const { serverName, edit } of perServerEdits) {
1496
+ const edits = formatWorkspaceEdit(edit, this.session.cwd);
1497
+ if (edits.length === 0) continue;
1498
+ lines.push(` ${serverName}:`);
1499
+ for (const e of edits) {
1500
+ lines.push(` ${e}`);
1501
+ }
1502
+ }
1503
+ }
1504
+ if (serverNotes.length > 0) {
1505
+ lines.push(" Server notes:");
1506
+ lines.push(...serverNotes);
1507
+ }
1508
+ return {
1509
+ content: [{ type: "text", text: lines.join("\n") }],
1510
+ details: {
1511
+ action,
1512
+ serverName: Array.from(respondingServers).join(", "),
1513
+ success: true,
1514
+ request: params,
1515
+ },
1516
+ };
1517
+ }
1518
+
1519
+ const summary: string[] = [];
1520
+ for (const { serverName, edit } of perServerEdits) {
1521
+ const applied = await applyWorkspaceEdit(edit, this.session.cwd);
1522
+ if (applied.length > 0) {
1523
+ summary.push(` ${serverName}:`);
1524
+ summary.push(...applied.map(line => ` ${line}`));
1525
+ }
1526
+ }
1527
+
1528
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
1529
+ await fs.promises.rename(source, dest);
1530
+ summary.push(` Renamed ${sourceLabel} → ${destLabel}`);
1531
+
1532
+ for (const [serverName, serverConfig] of servers) {
1533
+ try {
1534
+ const client = await getOrCreateClient(serverConfig, this.session.cwd);
1535
+ for (const { oldUri } of pairs) {
1536
+ if (client.openFiles.has(oldUri)) {
1537
+ await sendNotification(client, "textDocument/didClose", {
1538
+ textDocument: { uri: oldUri },
1539
+ });
1540
+ client.openFiles.delete(oldUri);
1541
+ }
1542
+ }
1543
+ await sendNotification(client, "workspace/didRenameFiles", lspParams);
1544
+ } catch (err) {
1545
+ if (err instanceof ToolAbortError || signal?.aborted) {
1546
+ throw err;
1547
+ }
1548
+ const msg = err instanceof Error ? err.message : String(err);
1549
+ serverNotes.push(` ${serverName}: ${msg}`);
1550
+ }
1551
+ }
1552
+
1553
+ if (serverNotes.length > 0) {
1554
+ summary.push(" Server notes:");
1555
+ summary.push(...serverNotes);
1556
+ }
1557
+
1558
+ const header = `Renamed ${fileCountLabel} → ${destLabel}`;
1559
+ return {
1560
+ content: [{ type: "text", text: `${header}\n${summary.join("\n")}` }],
1561
+ details: {
1562
+ action,
1563
+ serverName: Array.from(respondingServers).join(", "),
1564
+ success: true,
1565
+ request: params,
1566
+ },
1567
+ };
1568
+ }
1569
+
1570
+ if (action === "capabilities") {
1571
+ let serverList: Array<[string, ServerConfig]>;
1572
+ if (file && file !== "*") {
1573
+ const resolved = resolveToCwd(file, this.session.cwd);
1574
+ serverList = getLspServersForFile(config, resolved);
1575
+ if (serverList.length === 0) {
1576
+ return {
1577
+ content: [{ type: "text", text: "No language server found for this file" }],
1578
+ details: { action, success: false, request: params },
1579
+ };
1580
+ }
1581
+ } else {
1582
+ serverList = getLspServers(config);
1583
+ }
1584
+
1585
+ if (serverList.length === 0) {
1586
+ return {
1587
+ content: [{ type: "text", text: "No language servers configured" }],
1588
+ details: { action, success: false, request: params },
1589
+ };
1590
+ }
1591
+
1592
+ const sections: string[] = [];
1593
+ const respondingServers = new Set<string>();
1594
+ for (const [serverName, serverConfig] of serverList) {
1595
+ throwIfAborted(signal);
1596
+ try {
1597
+ const client = await getOrCreateClient(serverConfig, this.session.cwd);
1598
+ respondingServers.add(serverName);
1599
+ const caps = client.serverCapabilities ?? {};
1600
+ sections.push(`${serverName}:`);
1601
+ sections.push(` capabilities: ${JSON.stringify(caps, null, 2).split("\n").join("\n ")}`);
1602
+ } catch (err) {
1603
+ if (err instanceof ToolAbortError || signal?.aborted) {
1604
+ throw err;
1605
+ }
1606
+ const msg = err instanceof Error ? err.message : String(err);
1607
+ sections.push(`${serverName}: failed to start (${msg})`);
1608
+ }
1609
+ }
1610
+
1611
+ return {
1612
+ content: [{ type: "text", text: sections.join("\n") }],
1613
+ details: {
1614
+ action,
1615
+ serverName: Array.from(respondingServers).join(", "),
1616
+ success: true,
1617
+ request: params,
1618
+ },
1619
+ };
1620
+ }
1621
+
1622
+ if (action === "request") {
1623
+ const method = query?.trim();
1624
+ if (!method) {
1625
+ return {
1626
+ content: [
1627
+ {
1628
+ type: "text",
1629
+ text: "Error: action=request requires `query` to specify the LSP method name (e.g., 'rust-analyzer/expandMacro')",
1630
+ },
1631
+ ],
1632
+ details: { action, success: false, request: params },
1633
+ };
1634
+ }
1635
+
1636
+ let chosenServer: [string, ServerConfig] | null = null;
1637
+ let resolvedTarget: string | null = null;
1638
+ if (file && file !== "*") {
1639
+ resolvedTarget = resolveToCwd(file, this.session.cwd);
1640
+ chosenServer = getLspServerForFile(config, resolvedTarget);
1641
+ if (!chosenServer) {
1642
+ return {
1643
+ content: [{ type: "text", text: "No language server found for this file" }],
1644
+ details: { action, success: false, request: params },
1645
+ };
1646
+ }
1647
+ } else {
1648
+ const all = getLspServers(config);
1649
+ if (all.length === 0) {
1650
+ return {
1651
+ content: [{ type: "text", text: "No language servers configured" }],
1652
+ details: { action, success: false, request: params },
1653
+ };
1654
+ }
1655
+ chosenServer = all[0];
1656
+ }
1657
+
1658
+ const [chosenName, chosenConfig] = chosenServer;
1659
+ let requestParams: unknown;
1660
+ if (params.payload !== undefined) {
1661
+ try {
1662
+ requestParams = JSON.parse(params.payload);
1663
+ } catch (err) {
1664
+ const msg = err instanceof Error ? err.message : String(err);
1665
+ return {
1666
+ content: [{ type: "text", text: `Error: invalid JSON in payload: ${msg}` }],
1667
+ details: { action, serverName: chosenName, success: false, request: params },
1668
+ };
1669
+ }
1670
+ } else if (resolvedTarget) {
1671
+ const uri = fileToUri(resolvedTarget);
1672
+ if (line !== undefined) {
1673
+ const character = await resolveSymbolColumn(resolvedTarget, line, symbol);
1674
+ requestParams = { textDocument: { uri }, position: { line: line - 1, character } };
1675
+ } else {
1676
+ requestParams = { textDocument: { uri } };
1677
+ }
1678
+ } else {
1679
+ requestParams = {};
1680
+ }
1681
+
1682
+ try {
1683
+ const client = await getOrCreateClient(chosenConfig, this.session.cwd);
1684
+ if (resolvedTarget) {
1685
+ await ensureFileOpen(client, resolvedTarget, signal);
1686
+ }
1687
+ const result = await sendRequest(client, method, requestParams, signal);
1688
+ const formatted =
1689
+ result === null || result === undefined
1690
+ ? "null"
1691
+ : typeof result === "string"
1692
+ ? result
1693
+ : JSON.stringify(result, null, 2);
1694
+ return {
1695
+ content: [{ type: "text", text: `${chosenName} ← ${method}:\n${formatted}` }],
1696
+ details: { action, serverName: chosenName, success: true, request: params },
1697
+ };
1698
+ } catch (err) {
1699
+ if (err instanceof ToolAbortError || signal?.aborted) {
1700
+ throw new ToolAbortError();
1701
+ }
1702
+ const msg = err instanceof Error ? err.message : String(err);
1703
+ return {
1704
+ content: [{ type: "text", text: `LSP error from ${chosenName} on ${method}: ${msg}` }],
1705
+ details: { action, serverName: chosenName, success: false, request: params },
1706
+ };
1707
+ }
1708
+ }
1709
+
1315
1710
  // `*` means workspace scope for symbols/reload; other actions need a concrete file.
1316
1711
  const isWorkspace = file === "*";
1317
1712
  const requiresFile = !file && action !== "reload";
package/src/lsp/types.ts CHANGED
@@ -15,21 +15,32 @@ export const lspSchema = Type.Object({
15
15
  "hover",
16
16
  "symbols",
17
17
  "rename",
18
+ "rename_file",
18
19
  "code_actions",
19
20
  "type_definition",
20
21
  "implementation",
21
22
  "status",
22
23
  "reload",
24
+ "capabilities",
25
+ "request",
23
26
  ],
24
27
  { description: "LSP operation" },
25
28
  ),
26
- file: Type.Optional(Type.String({ description: "File path" })),
29
+ file: Type.Optional(Type.String({ description: "File path or source path for rename_file" })),
27
30
  line: Type.Optional(Type.Number({ description: "Line number (1-indexed)" })),
28
31
  symbol: Type.Optional(Type.String({ description: "Symbol/substring to locate on the line" })),
29
- query: Type.Optional(Type.String({ description: "Search query or SSR pattern" })),
30
- new_name: Type.Optional(Type.String({ description: "New name for rename" })),
31
- apply: Type.Optional(Type.Boolean({ description: "Apply edits (default: true)" })),
32
+ query: Type.Optional(
33
+ Type.String({ description: "Search query, code-action selector, or LSP method name for action=request" }),
34
+ ),
35
+ new_name: Type.Optional(Type.String({ description: "New name for rename, or destination path for rename_file" })),
36
+ apply: Type.Optional(Type.Boolean({ description: "Apply edits (default: true for rename/rename_file)" })),
32
37
  timeout: Type.Optional(Type.Number({ description: "Request timeout in seconds" })),
38
+ payload: Type.Optional(
39
+ Type.String({
40
+ description:
41
+ "JSON-encoded params for action=request. When omitted, params are auto-built from file/line/symbol.",
42
+ }),
43
+ ),
33
44
  });
34
45
 
35
46
  export type LspParams = Static<typeof lspSchema>;
package/src/main.ts CHANGED
@@ -242,6 +242,11 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
242
242
  }
243
243
 
244
244
  const lastVersion = settings.get("lastChangelogVersion");
245
+ if (lastVersion === VERSION) {
246
+ // Steady state: user already saw the current version's changelog. Skip the file read + parse.
247
+ return undefined;
248
+ }
249
+
245
250
  const changelogPath = getChangelogPath();
246
251
  const entries = await parseChangelog(changelogPath);
247
252
 
@@ -615,8 +620,19 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
615
620
  process.exit(1);
616
621
  }
617
622
 
623
+ // Kick off plugin-root preload in parallel with the remaining startup work.
624
+ // Awaited later (before extension/skill discovery in createAgentSession needs it).
625
+ const home = os.homedir();
626
+ const pluginPreloadPromise =
627
+ parsedArgs.pluginDirs && parsedArgs.pluginDirs.length > 0
628
+ ? logger.time("injectPluginDirRoots", injectPluginDirRoots, home, parsedArgs.pluginDirs, getProjectDir())
629
+ : logger.time("preloadPluginRoots", preloadPluginRoots, home, getProjectDir());
630
+ // Mark the promise as handled so a synchronous failure does not surface as an unhandled-rejection
631
+ // warning before we reach the await site below.
632
+ pluginPreloadPromise.catch(() => {});
633
+
618
634
  const cwd = getProjectDir();
619
- await logger.time("settings:init", Settings.init, { cwd });
635
+ const settingsInstance = await logger.time("settings:init", Settings.init, { cwd });
620
636
  if (parsedArgs.mode === "rpc") {
621
637
  applyRpcDefaultSettingOverrides();
622
638
  }
@@ -647,9 +663,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
647
663
  const mode = parsedArgs.mode || "text";
648
664
 
649
665
  // Initialize discovery system with settings for provider persistence
650
- logger.time("initializeWithSettings");
651
- initializeWithSettings(settings);
652
- modelRegistry.refreshInBackground();
666
+ logger.time("initializeWithSettings", initializeWithSettings, settings);
653
667
 
654
668
  // Apply model role overrides from CLI args or env vars (ephemeral, not persisted)
655
669
  const smolModel = parsedArgs.smol ?? $env.PI_SMOL_MODEL;
@@ -706,13 +720,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
706
720
  sessionManager = await SessionManager.open(selectedPath);
707
721
  }
708
722
 
709
- // Wire --plugin-dir and preload plugin roots for sync consumers (LSP config)
710
- const home = os.homedir();
711
- if (parsedArgs.pluginDirs && parsedArgs.pluginDirs.length > 0) {
712
- await logger.time("injectPluginDirRoots", injectPluginDirRoots, home, parsedArgs.pluginDirs!, getProjectDir());
713
- } else {
714
- await logger.time("preloadPluginRoots", preloadPluginRoots, home, getProjectDir());
715
- }
723
+ await pluginPreloadPromise;
716
724
 
717
725
  // Background marketplace auto-update — never blocks startup.
718
726
  const autoUpdate = settings.get("marketplace.autoUpdate");
@@ -759,6 +767,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
759
767
  sessionOptions.authStorage = authStorage;
760
768
  sessionOptions.modelRegistry = modelRegistry;
761
769
  sessionOptions.hasUI = isInteractive;
770
+ sessionOptions.settings = settingsInstance;
762
771
 
763
772
  // Handle CLI --api-key as runtime override (not persisted)
764
773
  if (parsedArgs.apiKey) {
@@ -778,7 +787,10 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
778
787
  createAgentSession,
779
788
  sessionOptions,
780
789
  );
781
- logger.time("main:afterCreateSession");
790
+ // Kick off background model discovery only after createAgentSession finishes its parallel
791
+ // discovery arms; running these concurrently contends for the event loop and stretches
792
+ // every parallel arm by ~30ms.
793
+ modelRegistry.refreshInBackground();
782
794
  if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
783
795
  authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
784
796
  }
@@ -856,8 +868,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
856
868
  await runAcpMode(session, createAcpSession);
857
869
  } else if (isInteractive) {
858
870
  const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
859
- logger.time("main:getChangelogForDisplay");
860
- const changelogMarkdown = await getChangelogForDisplay(parsedArgs);
871
+ const changelogMarkdown = await logger.time("main:getChangelogForDisplay", getChangelogForDisplay, parsedArgs);
861
872
 
862
873
  const scopedModelsForDisplay = sessionOptions.scopedModels ?? scopedModels;
863
874
  if (scopedModelsForDisplay.length > 0) {
@@ -5,9 +5,9 @@
5
5
  * by providing authorization URL, token URL, and client credentials.
6
6
  */
7
7
 
8
- import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai";
9
8
  import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/utils/oauth/callback-server";
10
9
  import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/utils/oauth/callback-server";
10
+ import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/utils/oauth/types";
11
11
 
12
12
  const DEFAULT_PORT = 3000;
13
13
  const CALLBACK_PATH = "/callback";
@@ -544,7 +544,7 @@ function shouldPersistResponseItemForMemories(message: AgentMessage): boolean {
544
544
  }
545
545
  if (role !== "toolResult") return false;
546
546
  const toolName = (message as { toolName?: string }).toolName;
547
- if (toolName === "bash" || toolName === "python" || toolName === "read" || toolName === "search") {
547
+ if (toolName === "bash" || toolName === "eval" || toolName === "read" || toolName === "search") {
548
548
  const text = extractMessageText(message);
549
549
  return text.length > 0 && text.length <= 32_000;
550
550
  }
@@ -104,7 +104,7 @@ export function mapToolKind(toolName: string): ToolKind {
104
104
  case "move":
105
105
  return "move";
106
106
  case "bash":
107
- case "python":
107
+ case "eval":
108
108
  return "execute";
109
109
  case "search":
110
110
  case "find":
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Component for displaying user-initiated Python execution with streaming output.
3
- * Shares the same kernel session as the agent's Python tool.
2
+ * Component for displaying user-initiated eval execution with streaming output.
3
+ * Shares the same kernel session as the agent's eval tool.
4
4
  */
5
5
 
6
6
  import { sanitizeText } from "@oh-my-pi/pi-natives";
@@ -13,7 +13,9 @@ import { truncateToVisualLines } from "./visual-truncate";
13
13
  const PREVIEW_LINES = 20;
14
14
  const MAX_DISPLAY_LINE_CHARS = 4000;
15
15
 
16
- export class PythonExecutionComponent extends Container {
16
+ export type EvalExecutionLanguage = "python" | "js";
17
+
18
+ export class EvalExecutionComponent extends Container {
17
19
  #outputLines: string[] = [];
18
20
  #status: "running" | "complete" | "cancelled" | "error" = "running";
19
21
  #exitCode: number | undefined = undefined;
@@ -22,10 +24,14 @@ export class PythonExecutionComponent extends Container {
22
24
  #expanded = false;
23
25
  #contentContainer: Container;
24
26
 
27
+ #highlightLang(): "python" | "javascript" {
28
+ return this.language === "js" ? "javascript" : "python";
29
+ }
30
+
25
31
  #formatHeader(colorKey: "dim" | "pythonMode"): Text {
26
32
  const prompt = theme.fg(colorKey, theme.bold(">>>"));
27
33
  const continuation = theme.fg(colorKey, " ");
28
- const codeLines = highlightCode(this.code, "python");
34
+ const codeLines = highlightCode(this.code, this.#highlightLang());
29
35
  const headerLines = codeLines.map((line, index) =>
30
36
  index === 0 ? `${prompt} ${line}` : `${continuation}${line}`,
31
37
  );
@@ -36,6 +42,7 @@ export class PythonExecutionComponent extends Container {
36
42
  private readonly code: string,
37
43
  ui: TUI,
38
44
  private readonly excludeFromContext = false,
45
+ private readonly language: EvalExecutionLanguage = "python",
39
46
  ) {
40
47
  super();
41
48
 
@@ -1,4 +1,4 @@
1
- import { getOAuthProviders } from "@oh-my-pi/pi-ai";
1
+ import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
2
2
  import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
  import { openPath } from "../../utils/open";
@@ -1,4 +1,5 @@
1
- import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
1
+ import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
2
+ import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/utils/oauth/types";
2
3
  import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
3
4
  import { theme } from "../../modes/theme/theme";
4
5
  import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
@@ -18,6 +18,7 @@ import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "..
18
18
  import type { Theme } from "../../modes/theme/theme";
19
19
  import { theme } from "../../modes/theme/theme";
20
20
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
21
+ import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
21
22
  import {
22
23
  formatArgsInline,
23
24
  JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -28,7 +29,6 @@ import {
28
29
  JSON_TREE_SCALAR_LEN_EXPANDED,
29
30
  renderJsonTreeLines,
30
31
  } from "../../tools/json-tree";
31
- import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
32
32
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
33
33
  import { toolRenderers } from "../../tools/renderers";
34
34
  import { renderStatusLine } from "../../tui";
@@ -668,12 +668,11 @@ export class ToolExecutionComponent extends Container {
668
668
  context.expanded = this.#expanded;
669
669
  context.previewLines = BASH_DEFAULT_PREVIEW_LINES;
670
670
  context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 3600);
671
- } else if (this.#toolName === "python" && this.#result) {
671
+ } else if (this.#toolName === "eval" && this.#result) {
672
672
  const output = this.#getTextOutput().trimEnd();
673
673
  context.output = output;
674
674
  context.expanded = this.#expanded;
675
- context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES;
676
- context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 600);
675
+ context.previewLines = EVAL_DEFAULT_PREVIEW_LINES;
677
676
  } else if (isEditLikeToolName(this.#toolName)) {
678
677
  context.editMode = this.#editMode;
679
678
  const previews = this.#editDiffPreview;