@oh-my-pi/pi-coding-agent 14.5.12 → 14.5.14

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 (112) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/commit/pipeline.ts +4 -3
  5. package/src/config/model-equivalence.ts +49 -16
  6. package/src/config/model-registry.ts +100 -25
  7. package/src/config/model-resolver.ts +29 -15
  8. package/src/config/settings-schema.ts +20 -6
  9. package/src/config/settings.ts +9 -8
  10. package/src/config.ts +18 -6
  11. package/src/eval/backend.ts +43 -0
  12. package/src/eval/eval.lark +43 -0
  13. package/src/eval/index.ts +5 -0
  14. package/src/eval/js/context-manager.ts +717 -0
  15. package/src/eval/js/executor.ts +131 -0
  16. package/src/eval/js/index.ts +46 -0
  17. package/src/eval/js/prelude.ts +2 -0
  18. package/src/eval/js/prelude.txt +84 -0
  19. package/src/eval/js/tool-bridge.ts +124 -0
  20. package/src/eval/parse.ts +337 -0
  21. package/src/{ipy → eval/py}/executor.ts +2 -180
  22. package/src/{ipy → eval/py}/gateway-coordinator.ts +2 -2
  23. package/src/eval/py/index.ts +58 -0
  24. package/src/{ipy → eval/py}/kernel.ts +9 -45
  25. package/src/{ipy → eval/py}/prelude.py +39 -227
  26. package/src/eval/types.ts +48 -0
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +8 -10
  29. package/src/extensibility/extensions/types.ts +2 -3
  30. package/src/internal-urls/docs-index.generated.ts +5 -5
  31. package/src/lsp/client.ts +9 -0
  32. package/src/lsp/index.ts +395 -0
  33. package/src/lsp/types.ts +15 -4
  34. package/src/main.ts +35 -14
  35. package/src/mcp/manager.ts +22 -0
  36. package/src/mcp/oauth-flow.ts +1 -1
  37. package/src/memories/index.ts +1 -1
  38. package/src/modes/acp/acp-event-mapper.ts +1 -1
  39. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  40. package/src/modes/components/login-dialog.ts +1 -1
  41. package/src/modes/components/oauth-selector.ts +2 -1
  42. package/src/modes/components/tool-execution.ts +3 -4
  43. package/src/modes/controllers/command-controller.ts +28 -8
  44. package/src/modes/controllers/input-controller.ts +4 -4
  45. package/src/modes/controllers/selector-controller.ts +2 -1
  46. package/src/modes/interactive-mode.ts +4 -5
  47. package/src/modes/rpc/rpc-client.ts +9 -0
  48. package/src/modes/rpc/rpc-mode.ts +6 -0
  49. package/src/modes/rpc/rpc-types.ts +9 -0
  50. package/src/modes/types.ts +3 -3
  51. package/src/modes/utils/ui-helpers.ts +2 -2
  52. package/src/prompts/system/system-prompt.md +3 -3
  53. package/src/prompts/tools/eval.md +92 -0
  54. package/src/prompts/tools/lsp.md +7 -3
  55. package/src/sdk.ts +64 -35
  56. package/src/session/agent-session.ts +152 -46
  57. package/src/session/messages.ts +1 -1
  58. package/src/slash-commands/builtin-registry.ts +1 -1
  59. package/src/system-prompt.ts +34 -66
  60. package/src/task/agents.ts +4 -5
  61. package/src/task/executor.ts +5 -9
  62. package/src/tools/archive-reader.ts +9 -3
  63. package/src/tools/browser/launch.ts +22 -0
  64. package/src/tools/browser/readable.ts +11 -6
  65. package/src/tools/browser/registry.ts +25 -244
  66. package/src/tools/browser/render.ts +1 -1
  67. package/src/tools/browser/tab-protocol.ts +101 -0
  68. package/src/tools/browser/tab-supervisor.ts +429 -0
  69. package/src/tools/browser/tab-worker-entry.ts +21 -0
  70. package/src/tools/browser/tab-worker.ts +1006 -0
  71. package/src/tools/browser.ts +17 -32
  72. package/src/tools/checkpoint.ts +2 -2
  73. package/src/tools/{python.ts → eval.ts} +324 -315
  74. package/src/tools/exit-plan-mode.ts +1 -1
  75. package/src/tools/image-gen.ts +2 -2
  76. package/src/tools/index.ts +62 -100
  77. package/src/tools/read.ts +0 -6
  78. package/src/tools/recipe/runners/pkg.ts +34 -32
  79. package/src/tools/renderers.ts +2 -2
  80. package/src/tools/resolve.ts +7 -2
  81. package/src/tools/todo-write.ts +0 -1
  82. package/src/tools/tool-timeouts.ts +2 -2
  83. package/src/tools/write.ts +8 -1
  84. package/src/utils/markit.ts +15 -7
  85. package/src/utils/tools-manager.ts +5 -5
  86. package/src/web/scrapers/crossref.ts +3 -3
  87. package/src/web/scrapers/devto.ts +1 -1
  88. package/src/web/scrapers/discourse.ts +5 -5
  89. package/src/web/scrapers/firefox-addons.ts +1 -1
  90. package/src/web/scrapers/flathub.ts +2 -2
  91. package/src/web/scrapers/gitlab.ts +1 -1
  92. package/src/web/scrapers/go-pkg.ts +2 -2
  93. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  94. package/src/web/scrapers/mastodon.ts +9 -9
  95. package/src/web/scrapers/mdn.ts +11 -7
  96. package/src/web/scrapers/pub-dev.ts +1 -1
  97. package/src/web/scrapers/rawg.ts +3 -3
  98. package/src/web/scrapers/readthedocs.ts +1 -1
  99. package/src/web/scrapers/spdx.ts +1 -1
  100. package/src/web/scrapers/stackoverflow.ts +2 -2
  101. package/src/web/scrapers/types.ts +53 -39
  102. package/src/web/scrapers/w3c.ts +1 -1
  103. package/src/web/search/index.ts +5 -5
  104. package/src/web/search/provider.ts +121 -39
  105. package/src/web/search/providers/gemini.ts +4 -4
  106. package/src/web/search/render.ts +2 -2
  107. package/src/ipy/modules.ts +0 -144
  108. package/src/prompts/tools/python.md +0 -57
  109. package/src/tools/browser/vm.ts +0 -792
  110. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  111. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  112. /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,18 +242,25 @@ 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
 
248
253
  if (!lastVersion) {
249
254
  if (entries.length > 0) {
250
255
  settings.set("lastChangelogVersion", VERSION);
256
+ await flushChangelogVersion();
251
257
  return entries.map(e => e.content).join("\n\n");
252
258
  }
253
259
  } else {
254
260
  const newEntries = getNewEntries(entries, lastVersion);
255
261
  if (newEntries.length > 0) {
256
262
  settings.set("lastChangelogVersion", VERSION);
263
+ await flushChangelogVersion();
257
264
  return newEntries.map(e => e.content).join("\n\n");
258
265
  }
259
266
  }
@@ -261,6 +268,14 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
261
268
  return undefined;
262
269
  }
263
270
 
271
+ async function flushChangelogVersion(): Promise<void> {
272
+ try {
273
+ await settings.flush();
274
+ } catch (error: unknown) {
275
+ logger.warn("Failed to persist lastChangelogVersion", { error });
276
+ }
277
+ }
278
+
264
279
  async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
265
280
  if (parsed.fork) {
266
281
  if (parsed.noSession) {
@@ -615,8 +630,19 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
615
630
  process.exit(1);
616
631
  }
617
632
 
633
+ // Kick off plugin-root preload in parallel with the remaining startup work.
634
+ // Awaited later (before extension/skill discovery in createAgentSession needs it).
635
+ const home = os.homedir();
636
+ const pluginPreloadPromise =
637
+ parsedArgs.pluginDirs && parsedArgs.pluginDirs.length > 0
638
+ ? logger.time("injectPluginDirRoots", injectPluginDirRoots, home, parsedArgs.pluginDirs, getProjectDir())
639
+ : logger.time("preloadPluginRoots", preloadPluginRoots, home, getProjectDir());
640
+ // Mark the promise as handled so a synchronous failure does not surface as an unhandled-rejection
641
+ // warning before we reach the await site below.
642
+ pluginPreloadPromise.catch(() => {});
643
+
618
644
  const cwd = getProjectDir();
619
- await logger.time("settings:init", Settings.init, { cwd });
645
+ const settingsInstance = await logger.time("settings:init", Settings.init, { cwd });
620
646
  if (parsedArgs.mode === "rpc") {
621
647
  applyRpcDefaultSettingOverrides();
622
648
  }
@@ -647,9 +673,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
647
673
  const mode = parsedArgs.mode || "text";
648
674
 
649
675
  // Initialize discovery system with settings for provider persistence
650
- logger.time("initializeWithSettings");
651
- initializeWithSettings(settings);
652
- modelRegistry.refreshInBackground();
676
+ logger.time("initializeWithSettings", initializeWithSettings, settings);
653
677
 
654
678
  // Apply model role overrides from CLI args or env vars (ephemeral, not persisted)
655
679
  const smolModel = parsedArgs.smol ?? $env.PI_SMOL_MODEL;
@@ -706,13 +730,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
706
730
  sessionManager = await SessionManager.open(selectedPath);
707
731
  }
708
732
 
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
- }
733
+ await pluginPreloadPromise;
716
734
 
717
735
  // Background marketplace auto-update — never blocks startup.
718
736
  const autoUpdate = settings.get("marketplace.autoUpdate");
@@ -759,6 +777,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
759
777
  sessionOptions.authStorage = authStorage;
760
778
  sessionOptions.modelRegistry = modelRegistry;
761
779
  sessionOptions.hasUI = isInteractive;
780
+ sessionOptions.settings = settingsInstance;
762
781
 
763
782
  // Handle CLI --api-key as runtime override (not persisted)
764
783
  if (parsedArgs.apiKey) {
@@ -778,7 +797,10 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
778
797
  createAgentSession,
779
798
  sessionOptions,
780
799
  );
781
- logger.time("main:afterCreateSession");
800
+ // Kick off background model discovery only after createAgentSession finishes its parallel
801
+ // discovery arms; running these concurrently contends for the event loop and stretches
802
+ // every parallel arm by ~30ms.
803
+ modelRegistry.refreshInBackground();
782
804
  if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
783
805
  authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
784
806
  }
@@ -856,8 +878,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
856
878
  await runAcpMode(session, createAcpSession);
857
879
  } else if (isInteractive) {
858
880
  const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
859
- logger.time("main:getChangelogForDisplay");
860
- const changelogMarkdown = await getChangelogForDisplay(parsedArgs);
881
+ const changelogMarkdown = await logger.time("main:getChangelogForDisplay", getChangelogForDisplay, parsedArgs);
861
882
 
862
883
  const scopedModelsForDisplay = sessionOptions.scopedModels ?? scopedModels;
863
884
  if (scopedModelsForDisplay.length > 0) {
@@ -78,6 +78,21 @@ function delay(ms: number): Promise<void> {
78
78
  return Bun.sleep(ms);
79
79
  }
80
80
 
81
+ /**
82
+ * Stable, total ordering on MCP tools by name.
83
+ *
84
+ * Anthropic prompt caching keys on byte-identical tool definitions: any reorder
85
+ * of the tools array invalidates the tools cache breakpoint and forces a full
86
+ * prefix rebuild on the next request. MCP servers connect/reconnect at arbitrary
87
+ * times, so the natural "insertion order" of `#tools` is non-deterministic.
88
+ * Sorting after every mutation makes the array bytes independent of connection
89
+ * sequence.
90
+ */
91
+ export function sortMCPToolsByName<T extends { name: string }>(tools: T[]): T[] {
92
+ tools.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
93
+ return tools;
94
+ }
95
+
81
96
  export function resolveSubscriptionPostAction(
82
97
  notificationsEnabled: boolean,
83
98
  currentEpoch: number,
@@ -459,6 +474,10 @@ export class MCPManager {
459
474
  }
460
475
  }
461
476
 
477
+ // Stable sort by name so the order is independent of connection completion.
478
+ // See `sortMCPToolsByName` for the cache-stability rationale.
479
+ sortMCPToolsByName(allTools);
480
+
462
481
  // Update cached tools
463
482
  this.#tools = allTools;
464
483
  allowBackgroundLogging = true;
@@ -474,6 +493,9 @@ export class MCPManager {
474
493
  #replaceServerTools(name: string, tools: CustomTool<TSchema, MCPToolDetails>[]): void {
475
494
  this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp__${name}_`));
476
495
  this.#tools.push(...tools);
496
+ // Stable sort by name so reconnect order does not perturb the array.
497
+ // See `sortMCPToolsByName` for the cache-stability rationale.
498
+ sortMCPToolsByName(this.#tools);
477
499
  }
478
500
 
479
501
  #triggerNotificationRefresh(serverName: string, kind: "tools" | "resources" | "prompts"): void {
@@ -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":