@oh-my-pi/pi-coding-agent 14.5.13 → 14.6.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 (105) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/commit/pipeline.ts +4 -3
  18. package/src/config/settings-schema.ts +1 -1
  19. package/src/config/settings.ts +20 -1
  20. package/src/config.ts +9 -6
  21. package/src/cursor.ts +1 -1
  22. package/src/edit/index.ts +9 -31
  23. package/src/edit/line-hash.ts +70 -43
  24. package/src/edit/modes/hashline.lark +26 -0
  25. package/src/edit/modes/hashline.ts +898 -1099
  26. package/src/edit/modes/patch.ts +0 -7
  27. package/src/edit/modes/replace.ts +0 -4
  28. package/src/edit/renderer.ts +22 -20
  29. package/src/edit/streaming.ts +8 -28
  30. package/src/eval/eval.lark +24 -30
  31. package/src/eval/js/context-manager.ts +5 -162
  32. package/src/eval/js/prelude.txt +0 -12
  33. package/src/eval/parse.ts +129 -129
  34. package/src/eval/py/kernel.ts +4 -4
  35. package/src/eval/py/prelude.py +1 -219
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +2 -2
  38. package/src/internal-urls/docs-index.generated.ts +1 -1
  39. package/src/main.ts +10 -0
  40. package/src/mcp/manager.ts +22 -0
  41. package/src/modes/components/session-observer-overlay.ts +5 -2
  42. package/src/modes/components/status-line/segments.ts +1 -1
  43. package/src/modes/components/status-line.ts +3 -5
  44. package/src/modes/components/tree-selector.ts +4 -5
  45. package/src/modes/components/welcome.ts +11 -1
  46. package/src/modes/controllers/command-controller.ts +2 -6
  47. package/src/modes/controllers/event-controller.ts +1 -2
  48. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  49. package/src/modes/controllers/input-controller.ts +0 -1
  50. package/src/modes/controllers/selector-controller.ts +1 -1
  51. package/src/modes/interactive-mode.ts +5 -7
  52. package/src/modes/rpc/rpc-client.ts +9 -0
  53. package/src/modes/rpc/rpc-mode.ts +6 -0
  54. package/src/modes/rpc/rpc-types.ts +9 -0
  55. package/src/prompts/system/system-prompt.md +14 -38
  56. package/src/prompts/tools/ast-edit.md +8 -8
  57. package/src/prompts/tools/ast-grep.md +10 -10
  58. package/src/prompts/tools/eval.md +13 -31
  59. package/src/prompts/tools/find.md +2 -1
  60. package/src/prompts/tools/hashline.md +66 -57
  61. package/src/prompts/tools/search.md +2 -2
  62. package/src/sdk.ts +19 -4
  63. package/src/session/agent-session.ts +110 -4
  64. package/src/session/session-manager.ts +17 -13
  65. package/src/task/agents.ts +4 -5
  66. package/src/tools/archive-reader.ts +9 -3
  67. package/src/tools/ast-edit.ts +141 -44
  68. package/src/tools/ast-grep.ts +112 -36
  69. package/src/tools/browser/readable.ts +11 -6
  70. package/src/tools/browser/tab-supervisor.ts +2 -2
  71. package/src/tools/browser.ts +5 -3
  72. package/src/tools/eval.ts +2 -53
  73. package/src/tools/find.ts +16 -15
  74. package/src/tools/image-gen.ts +2 -2
  75. package/src/tools/path-utils.ts +36 -196
  76. package/src/tools/search.ts +56 -35
  77. package/src/tools/write.ts +8 -1
  78. package/src/utils/edit-mode.ts +2 -11
  79. package/src/utils/file-display-mode.ts +1 -1
  80. package/src/utils/git.ts +17 -0
  81. package/src/utils/session-color.ts +0 -12
  82. package/src/utils/title-generator.ts +22 -38
  83. package/src/web/scrapers/crossref.ts +3 -3
  84. package/src/web/scrapers/devto.ts +1 -1
  85. package/src/web/scrapers/discourse.ts +5 -5
  86. package/src/web/scrapers/firefox-addons.ts +1 -1
  87. package/src/web/scrapers/flathub.ts +2 -2
  88. package/src/web/scrapers/gitlab.ts +1 -1
  89. package/src/web/scrapers/go-pkg.ts +2 -2
  90. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  91. package/src/web/scrapers/mastodon.ts +9 -9
  92. package/src/web/scrapers/mdn.ts +11 -7
  93. package/src/web/scrapers/pub-dev.ts +1 -1
  94. package/src/web/scrapers/rawg.ts +3 -3
  95. package/src/web/scrapers/readthedocs.ts +1 -1
  96. package/src/web/scrapers/spdx.ts +1 -1
  97. package/src/web/scrapers/stackoverflow.ts +2 -2
  98. package/src/web/scrapers/types.ts +53 -39
  99. package/src/web/scrapers/w3c.ts +1 -1
  100. package/src/web/search/providers/gemini.ts +2 -2
  101. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  102. package/src/autoresearch/contract.ts +0 -288
  103. package/src/edit/modes/atom.lark +0 -29
  104. package/src/edit/modes/atom.ts +0 -1773
  105. package/src/prompts/tools/atom.md +0 -150
package/src/sdk.ts CHANGED
@@ -300,7 +300,6 @@ function getDefaultAgentDir(): string {
300
300
  */
301
301
  export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise<AuthStorage> {
302
302
  const dbPath = getAgentDbPath(agentDir);
303
- logger.debug("discoverAuthStorage", { agentDir, dbPath });
304
303
 
305
304
  const storage = await AuthStorage.create(dbPath, { configValueResolver: resolveConfigValue });
306
305
  await storage.reload();
@@ -429,6 +428,9 @@ function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool {
429
428
 
430
429
  const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition");
431
430
 
431
+ /** Matches the truncation applied to per-server instructions inside `rebuildSystemPrompt`. */
432
+ const MAX_MCP_INSTRUCTIONS_LENGTH = 4000;
433
+
432
434
  let sshCleanupRegistered = false;
433
435
 
434
436
  async function cleanupSshResources(): Promise<void> {
@@ -1338,7 +1340,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1338
1340
  const serverInstructions = mcpManager?.getServerInstructions();
1339
1341
  let appendPrompt: string | undefined = memoryInstructions ?? undefined;
1340
1342
  if (serverInstructions && serverInstructions.size > 0) {
1341
- const MAX_INSTRUCTIONS_LENGTH = 4000;
1342
1343
  const parts: string[] = [];
1343
1344
  if (appendPrompt) parts.push(appendPrompt);
1344
1345
  parts.push(
@@ -1346,8 +1347,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1346
1347
  );
1347
1348
  for (const [srvName, srvInstructions] of serverInstructions) {
1348
1349
  const truncated =
1349
- srvInstructions.length > MAX_INSTRUCTIONS_LENGTH
1350
- ? `${srvInstructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}\n[truncated]`
1350
+ srvInstructions.length > MAX_MCP_INSTRUCTIONS_LENGTH
1351
+ ? `${srvInstructions.slice(0, MAX_MCP_INSTRUCTIONS_LENGTH)}\n[truncated]`
1351
1352
  : srvInstructions;
1352
1353
  parts.push(`### ${srvName}\n${truncated}`);
1353
1354
  }
@@ -1627,6 +1628,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1627
1628
  onResponse,
1628
1629
  convertToLlm: convertToLlmFinal,
1629
1630
  rebuildSystemPrompt,
1631
+ getMcpServerInstructions: mcpManager
1632
+ ? () => {
1633
+ const raw = mcpManager.getServerInstructions();
1634
+ if (!raw || raw.size === 0) return raw;
1635
+ const out = new Map<string, string>();
1636
+ for (const [name, text] of raw) {
1637
+ out.set(
1638
+ name,
1639
+ text.length > MAX_MCP_INSTRUCTIONS_LENGTH ? text.slice(0, MAX_MCP_INSTRUCTIONS_LENGTH) : text,
1640
+ );
1641
+ }
1642
+ return out;
1643
+ }
1644
+ : undefined,
1630
1645
  mcpDiscoveryEnabled,
1631
1646
  initialSelectedMCPToolNames,
1632
1647
  defaultSelectedMCPToolNames,
@@ -244,6 +244,13 @@ export interface AgentSessionConfig {
244
244
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
245
245
  /** System prompt builder that can consider tool availability */
246
246
  rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
247
+ /**
248
+ * Optional accessor for live MCP server instructions. Read by the session's
249
+ * `rebuildSystemPrompt`-skip optimization to detect server-side instruction
250
+ * changes (e.g. an MCP server upgrade) that would otherwise pass the tool-set
251
+ * signature comparison and silently keep a stale prompt cached.
252
+ */
253
+ getMcpServerInstructions?: () => Map<string, string> | undefined;
247
254
  /** Enable hidden-by-default MCP tool discovery for this session. */
248
255
  mcpDiscoveryEnabled?: boolean;
249
256
  /** MCP tool names to activate for the current session when discovery mode is enabled. */
@@ -511,7 +518,15 @@ export class AgentSession {
511
518
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
512
519
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
513
520
  #rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
521
+ #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
514
522
  #baseSystemPrompt: string;
523
+ /**
524
+ * Signature of the (toolNames, tool descriptions) tuple passed to the most
525
+ * recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds
526
+ * when MCP servers reconnect without changing their tool definitions, which is
527
+ * the dominant cause of prompt-cache invalidation in long sessions.
528
+ */
529
+ #lastAppliedToolSignature: string | undefined;
515
530
  #mcpDiscoveryEnabled = false;
516
531
  #discoverableMCPTools = new Map<string, DiscoverableMCPTool>();
517
532
  #discoverableMCPSearchIndex: DiscoverableMCPSearchIndex | null = null;
@@ -595,6 +610,7 @@ export class AgentSession {
595
610
  this.#onResponse = config.onResponse;
596
611
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
597
612
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
613
+ this.#getMcpServerInstructions = config.getMcpServerInstructions;
598
614
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
599
615
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
600
616
  this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
@@ -2211,10 +2227,18 @@ export class AgentSession {
2211
2227
  }
2212
2228
  this.agent.setTools(tools);
2213
2229
 
2214
- // Rebuild base system prompt with new tool set
2230
+ // Rebuild base system prompt with new tool set, but only when the tool set
2231
+ // actually changed. MCP servers can reconnect at arbitrary times and call
2232
+ // `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting
2233
+ // tool list is byte-identical. Skipping the rebuild keeps the system prompt
2234
+ // stable, which is required for Anthropic prompt caching to keep hitting.
2215
2235
  if (this.#rebuildSystemPrompt) {
2216
- this.#baseSystemPrompt = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
2217
- this.agent.setSystemPrompt(this.#baseSystemPrompt);
2236
+ const signature = this.#computeAppliedToolSignature(validToolNames, tools);
2237
+ if (signature !== this.#lastAppliedToolSignature) {
2238
+ this.#baseSystemPrompt = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
2239
+ this.agent.setSystemPrompt(this.#baseSystemPrompt);
2240
+ this.#lastAppliedToolSignature = signature;
2241
+ }
2218
2242
  }
2219
2243
  if (options?.persistMCPSelection !== false) {
2220
2244
  this.#persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames);
@@ -2256,6 +2280,86 @@ export class AgentSession {
2256
2280
  const activeToolNames = this.getActiveToolNames();
2257
2281
  this.#baseSystemPrompt = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
2258
2282
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
2283
+ // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
2284
+ // the same tool set does not re-rebuild on top of the explicit refresh we
2285
+ // just performed (and conversely, a different set forces a fresh rebuild).
2286
+ const activeTools = activeToolNames
2287
+ .map(name => this.#toolRegistry.get(name))
2288
+ .filter((tool): tool is AgentTool => tool != null);
2289
+ this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
2290
+ }
2291
+
2292
+ /**
2293
+ * Compose a stable signature for the inputs that `rebuildSystemPrompt` reads.
2294
+ * Two calls producing identical signatures are guaranteed to produce identical
2295
+ * system prompt bytes, so the rebuild can be skipped.
2296
+ *
2297
+ * The signature covers:
2298
+ * 1. Active tool names in order (the prompt renders them in this order).
2299
+ * 2. Active tool labels, descriptions, and wire-visible names — all are
2300
+ * rendered into the prompt body (see `system-prompt.md` `{{label}}: \`{{name}}\``
2301
+ * and `toolPromptNames` in `buildSystemPrompt`). The wire name comes from
2302
+ * `tool.customWireName` and overrides the internal name on the model wire
2303
+ * (e.g. `edit` exposes itself as `apply_patch` to GPT-5 in apply_patch mode);
2304
+ * a stale wire name would desync prompt guidance from actual tool routing.
2305
+ * 3. When MCP discovery is on, every registry tool's name+label+description+
2306
+ * customWireName, since `rebuildSystemPrompt` summarizes discoverable MCP
2307
+ * tools that are not in the active set.
2308
+ * 4. MCP server instructions text (per server), since `rebuildSystemPrompt`
2309
+ * embeds these in the appended prompt under "## MCP Server Instructions".
2310
+ * A server upgrade can change instructions while keeping tools identical.
2311
+ *
2312
+ * Settings-driven tool metadata is covered automatically: built-in tools that
2313
+ * depend on settings expose `description`/`label` via getters (see `TaskTool`,
2314
+ * `SearchToolBm25Tool`, `EditTool`), and the signature reads them live on every
2315
+ * call - so a settings flip that mutates the rendered string differs the signature
2316
+ * the next time `#applyActiveToolsByName` runs. Do not refactor `describeTool` to
2317
+ * cache per-tool strings without preserving this property.
2318
+ *
2319
+ * Inputs NOT covered: tool input schemas; memory instructions read from disk;
2320
+ * and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`,
2321
+ * `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
2322
+ * closure-captured ones cannot change at runtime regardless of skip behavior.
2323
+ * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
2324
+ * after side-effecting changes; see e.g. the memory hooks and
2325
+ * `#syncEditToolModeAfterModelChange`.
2326
+ *
2327
+ * The current calendar date IS covered (appended as a segment) because
2328
+ * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
2329
+ * Without this, a session spanning midnight with only tool-stable MCP
2330
+ * reconnects would keep yesterday's date indefinitely.
2331
+ */
2332
+ #computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
2333
+ // Order-preserving join: any reorder must produce a different signature so
2334
+ // the rebuild fires and the new tool list reaches the API.
2335
+ const nameSegment = toolNames.join("\u0001");
2336
+ const describeTool = (tool: AgentTool): string =>
2337
+ `${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
2338
+ const descriptionSegment = tools.map(describeTool).join("\u0002");
2339
+ let registrySegment = "";
2340
+ if (this.#mcpDiscoveryEnabled) {
2341
+ // Registry iteration order is not load-bearing for the prompt content, so we
2342
+ // sort to keep the signature insensitive to incidental insertion order.
2343
+ const entries: string[] = [];
2344
+ for (const tool of this.#toolRegistry.values()) {
2345
+ entries.push(describeTool(tool));
2346
+ }
2347
+ entries.sort();
2348
+ registrySegment = entries.join("\u0004");
2349
+ }
2350
+ let instructionsSegment = "";
2351
+ const serverInstructions = this.#getMcpServerInstructions?.();
2352
+ if (serverInstructions && serverInstructions.size > 0) {
2353
+ // Sort by server name so transport flap order does not perturb the signature.
2354
+ const entries: string[] = [];
2355
+ for (const [server, instructions] of serverInstructions) {
2356
+ entries.push(`${server}=${instructions}`);
2357
+ }
2358
+ entries.sort();
2359
+ instructionsSegment = entries.join("\u0006");
2360
+ }
2361
+ const date = new Date().toISOString().slice(0, 10);
2362
+ return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`;
2259
2363
  }
2260
2364
 
2261
2365
  /**
@@ -4248,9 +4352,10 @@ export class AgentSession {
4248
4352
  }
4249
4353
 
4250
4354
  // Start a new session
4355
+ const previousSessionFile = this.sessionFile;
4251
4356
  await this.sessionManager.flush();
4252
4357
  this.#asyncJobManager?.cancelAll();
4253
- await this.sessionManager.newSession();
4358
+ await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
4254
4359
  this.agent.reset();
4255
4360
  this.agent.sessionId = this.sessionManager.getSessionId();
4256
4361
  this.#steeringMessages = [];
@@ -4262,6 +4367,7 @@ export class AgentSession {
4262
4367
  // Inject the handoff document as a custom message
4263
4368
  const handoffContent = `<handoff-context>\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
4264
4369
  this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
4370
+ await this.sessionManager.ensureOnDisk();
4265
4371
  let savedPath: string | undefined;
4266
4372
  if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
4267
4373
  const artifactsDir = this.sessionManager.getArtifactsDir();
@@ -870,8 +870,8 @@ function sanitizeSessionName(value: string | undefined): string | undefined {
870
870
 
871
871
  class RecentSessionInfo {
872
872
  #fullName: string | undefined;
873
- #name: string | undefined;
874
873
  #timeAgo: string | undefined;
874
+ readonly #headerTimestamp: string | undefined;
875
875
 
876
876
  constructor(
877
877
  readonly path: string,
@@ -879,27 +879,31 @@ class RecentSessionInfo {
879
879
  header: Record<string, unknown>,
880
880
  firstPrompt?: string,
881
881
  ) {
882
- // Extract title from session header, falling back to first user prompt, then id
882
+ // Prefer an explicit title, then the first user prompt. The raw UUID `id` is
883
+ // intentionally not used as a fallback: showing it as a "name" is unfriendly and
884
+ // indistinguishable from neighboring sessions in the UI. The friendly fallback is
885
+ // derived lazily in `fullName` from the session timestamp.
883
886
  const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
884
- this.#fullName =
885
- sanitizeSessionName(trystr(header.title)) ??
886
- sanitizeSessionName(firstPrompt) ??
887
- sanitizeSessionName(trystr(header.id));
887
+ this.#fullName = sanitizeSessionName(trystr(header.title)) ?? sanitizeSessionName(firstPrompt);
888
+ this.#headerTimestamp = trystr(header.timestamp);
888
889
  }
889
890
 
890
- /** Full session name from header, or filename without extension as fallback */
891
+ /** Display name. Falls back to a timestamp-based label, never the raw UUID. */
891
892
  get fullName(): string {
892
893
  if (this.#fullName) return this.#fullName;
893
- this.#fullName = this.path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
894
+ const ts = this.#headerTimestamp ? Date.parse(this.#headerTimestamp) : Number.NaN;
895
+ const date = new Date(Number.isFinite(ts) ? ts : this.mtime);
896
+ const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
897
+ this.#fullName = `Untitled · ${time}`;
894
898
  return this.#fullName;
895
899
  }
896
900
 
897
- /** Truncated name for display (max 40 chars) */
901
+ /**
902
+ * Display name without an arbitrary length cap. The renderer is responsible for
903
+ * width-aware truncation so adjacent fields (e.g. the relative time) stay visible.
904
+ */
898
905
  get name(): string {
899
- if (this.#name) return this.#name;
900
- const fullName = this.fullName;
901
- this.#name = fullName.length <= 40 ? fullName : `${fullName.slice(0, 39)}…`;
902
- return this.#name;
906
+ return this.fullName;
903
907
  }
904
908
 
905
909
  /** Human-readable relative time (e.g., "2 hours ago") */
@@ -69,10 +69,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
69
69
  },
70
70
  ];
71
71
 
72
- const EMBEDDED_AGENTS: { name: string; content: string }[] = EMBEDDED_AGENT_DEFS.map(def => ({
73
- name: def.fileName,
74
- content: buildAgentContent(def),
75
- }));
72
+ // Computed lazily on first loadBundledAgents() call to avoid eager prompt.render at module load.
76
73
 
77
74
  export class AgentParsingError extends Error {
78
75
  constructor(
@@ -133,7 +130,9 @@ export function loadBundledAgents(): AgentDefinition[] {
133
130
  if (bundledAgentsCache !== null) {
134
131
  return bundledAgentsCache;
135
132
  }
136
- bundledAgentsCache = EMBEDDED_AGENTS.map(({ name, content }) => parseAgent(`embedded:${name}`, content, "bundled"));
133
+ bundledAgentsCache = EMBEDDED_AGENT_DEFS.map(def =>
134
+ parseAgent(`embedded:${def.fileName}`, buildAgentContent(def), "bundled"),
135
+ );
137
136
  return bundledAgentsCache;
138
137
  }
139
138
 
@@ -1,6 +1,11 @@
1
- import { unzipSync } from "fflate";
2
1
  import { ToolError } from "./tool-errors";
3
2
 
3
+ let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
4
+ async function loadFflate(): Promise<typeof import("fflate")> {
5
+ if (!fflateModulePromise) fflateModulePromise = import("fflate");
6
+ return fflateModulePromise;
7
+ }
8
+
4
9
  export type ArchiveFormat = "zip" | "tar" | "tar.gz";
5
10
 
6
11
  export interface ArchivePathCandidate {
@@ -150,7 +155,8 @@ async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
150
155
  return entries;
151
156
  }
152
157
 
153
- function readZipEntries(bytes: Uint8Array): ArchiveIndexEntry[] {
158
+ async function readZipEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
159
+ const { unzipSync } = await loadFflate();
154
160
  let files: Record<string, Uint8Array>;
155
161
  try {
156
162
  files = unzipSync(bytes);
@@ -310,6 +316,6 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
310
316
  }
311
317
 
312
318
  const bytes = await Bun.file(filePath).bytes();
313
- const entries = format === "zip" ? readZipEntries(bytes) : await readTarEntries(bytes);
319
+ const entries = format === "zip" ? await readZipEntries(bytes) : await readTarEntries(bytes);
314
320
  return new ArchiveReader(format, entries);
315
321
  }
@@ -1,5 +1,6 @@
1
+ import * as path from "node:path";
1
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
- import { type AstReplaceChange, astEdit } from "@oh-my-pi/pi-natives";
3
+ import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-my-pi/pi-natives";
3
4
  import type { Component } from "@oh-my-pi/pi-tui";
4
5
  import { Text } from "@oh-my-pi/pi-tui";
5
6
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
@@ -19,7 +20,7 @@ import {
19
20
  hasGlobPathChars,
20
21
  normalizePathLikeInput,
21
22
  parseSearchPath,
22
- resolveMultiSearchPath,
23
+ resolveExplicitSearchPaths,
23
24
  resolveToCwd,
24
25
  } from "./path-utils";
25
26
  import {
@@ -46,12 +47,106 @@ const astEditSchema = Type.Object({
46
47
  minItems: 1,
47
48
  description: "rewrite ops",
48
49
  }),
49
- path: Type.String({
50
- description: "file, directory, glob, or comma-separated paths to rewrite",
51
- examples: ["src/", "src/foo.ts", "src/**/*.ts"],
50
+ paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to rewrite" }), {
51
+ minItems: 1,
52
+ description: "files, directories, globs, or internal URLs to rewrite",
53
+ examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
52
54
  }),
53
55
  });
54
56
 
57
+ interface AstEditCallOptions {
58
+ rewrites: Record<string, string>;
59
+ dryRun: boolean;
60
+ maxFiles: number;
61
+ failOnParseError: boolean;
62
+ signal?: AbortSignal;
63
+ }
64
+
65
+ interface AstEditAggregatedResult {
66
+ changes: AstReplaceChange[];
67
+ fileChanges: AstReplaceFileChange[];
68
+ totalReplacements: number;
69
+ filesTouched: number;
70
+ filesSearched: number;
71
+ applied: boolean;
72
+ limitReached: boolean;
73
+ parseErrors?: string[];
74
+ }
75
+
76
+ async function runAstEditTargets(
77
+ targets: Array<{ basePath: string; glob?: string }>,
78
+ commonBasePath: string,
79
+ options: AstEditCallOptions,
80
+ ): Promise<AstEditAggregatedResult> {
81
+ const aggregatedChanges: AstReplaceChange[] = [];
82
+ const fileCounts = new Map<string, number>();
83
+ const parseErrors: string[] = [];
84
+ let totalReplacements = 0;
85
+ let filesSearched = 0;
86
+ let limitReached = false;
87
+ let applied = !options.dryRun;
88
+ for (const target of targets) {
89
+ const targetResult = await astEdit({
90
+ rewrites: options.rewrites,
91
+ path: target.basePath,
92
+ glob: target.glob,
93
+ dryRun: options.dryRun,
94
+ maxFiles: options.maxFiles,
95
+ failOnParseError: options.failOnParseError,
96
+ signal: options.signal,
97
+ });
98
+ totalReplacements += targetResult.totalReplacements;
99
+ filesSearched += targetResult.filesSearched;
100
+ limitReached = limitReached || targetResult.limitReached;
101
+ applied = applied && targetResult.applied;
102
+ if (targetResult.parseErrors) parseErrors.push(...targetResult.parseErrors);
103
+ for (const change of targetResult.changes) {
104
+ const absolute = path.resolve(target.basePath, change.path);
105
+ const rebased = path.relative(commonBasePath, absolute).replace(/\\/g, "/");
106
+ aggregatedChanges.push({ ...change, path: rebased });
107
+ }
108
+ for (const fileChange of targetResult.fileChanges) {
109
+ const absolute = path.resolve(target.basePath, fileChange.path);
110
+ const rebased = path.relative(commonBasePath, absolute).replace(/\\/g, "/");
111
+ fileCounts.set(rebased, (fileCounts.get(rebased) ?? 0) + fileChange.count);
112
+ }
113
+ }
114
+ const fileChanges: AstReplaceFileChange[] = Array.from(fileCounts, ([changePath, count]) => ({
115
+ path: changePath,
116
+ count,
117
+ }));
118
+ return {
119
+ changes: aggregatedChanges,
120
+ fileChanges,
121
+ totalReplacements,
122
+ filesTouched: fileChanges.length,
123
+ filesSearched,
124
+ applied,
125
+ limitReached,
126
+ parseErrors: parseErrors.length > 0 ? parseErrors : undefined,
127
+ };
128
+ }
129
+
130
+ function runAstEditOnce(
131
+ targets: Array<{ basePath: string; glob?: string }> | undefined,
132
+ resolvedSearchPath: string,
133
+ globFilter: string | undefined,
134
+ options: AstEditCallOptions,
135
+ ): Promise<AstEditAggregatedResult> {
136
+ if (targets) {
137
+ return runAstEditTargets(targets, resolvedSearchPath, options);
138
+ }
139
+ return astEdit({
140
+ rewrites: options.rewrites,
141
+ path: resolvedSearchPath,
142
+ glob: globFilter,
143
+ dryRun: options.dryRun,
144
+ maxFiles: options.maxFiles,
145
+ failOnParseError: options.failOnParseError,
146
+ signal: options.signal,
147
+ });
148
+ }
149
+
55
150
  export interface AstEditToolDetails {
56
151
  totalReplacements: number;
57
152
  filesTouched: number;
@@ -107,40 +202,46 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
107
202
  const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
108
203
 
109
204
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
110
- let searchPath: string | undefined;
111
- let scopePath: string | undefined;
205
+ let searchPath: string;
206
+ let scopePath: string;
112
207
  let globFilter: string | undefined;
113
- const rawPath = normalizePathLikeInput(params.path);
114
- if (rawPath.length === 0) {
115
- throw new ToolError("`path` must be a non-empty path or glob");
208
+ let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
209
+ const rawPaths = params.paths.map(normalizePathLikeInput);
210
+ if (rawPaths.some(rawPath => rawPath.length === 0)) {
211
+ throw new ToolError("`paths` must contain non-empty paths or globs");
116
212
  }
117
- if (rawPath) {
118
- const internalRouter = this.session.internalRouter;
119
- if (internalRouter?.canHandle(rawPath)) {
120
- if (hasGlobPathChars(rawPath)) {
121
- throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
122
- }
123
- const resource = await internalRouter.resolve(rawPath);
124
- if (!resource.sourcePath) {
125
- throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
126
- }
127
- searchPath = resource.sourcePath;
128
- scopePath = formatScopePath(searchPath);
129
- } else {
130
- const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
131
- if (multiSearchPath) {
132
- searchPath = multiSearchPath.basePath;
133
- globFilter = multiSearchPath.glob;
134
- scopePath = multiSearchPath.scopePath;
135
- } else {
136
- const parsedPath = parseSearchPath(rawPath);
137
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
138
- globFilter = parsedPath.glob;
139
- scopePath = formatScopePath(searchPath);
140
- }
213
+ const internalRouter = this.session.internalRouter;
214
+ const resolvedPathInputs: string[] = [];
215
+ for (const rawPath of rawPaths) {
216
+ if (!internalRouter?.canHandle(rawPath)) {
217
+ resolvedPathInputs.push(rawPath);
218
+ continue;
219
+ }
220
+ if (hasGlobPathChars(rawPath)) {
221
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
222
+ }
223
+ const resource = await internalRouter.resolve(rawPath);
224
+ if (!resource.sourcePath) {
225
+ throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
226
+ }
227
+ resolvedPathInputs.push(resource.sourcePath);
228
+ }
229
+ if (resolvedPathInputs.length === 1) {
230
+ const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
231
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
232
+ globFilter = parsedPath.glob;
233
+ scopePath = formatScopePath(searchPath);
234
+ } else {
235
+ const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
236
+ if (!multiSearchPath) {
237
+ throw new ToolError("`paths` must contain at least one path or glob");
141
238
  }
239
+ searchPath = multiSearchPath.basePath;
240
+ globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
241
+ multiTargets = multiSearchPath.targets;
242
+ scopePath = multiSearchPath.scopePath;
142
243
  }
143
- const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
244
+ const resolvedSearchPath = searchPath;
144
245
  scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
145
246
  let isDirectory: boolean;
146
247
  try {
@@ -150,10 +251,8 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
150
251
  throw new ToolError(`Path not found: ${scopePath}`);
151
252
  }
152
253
 
153
- const result = await astEdit({
254
+ const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
154
255
  rewrites: normalizedRewrites,
155
- path: resolvedSearchPath,
156
- glob: globFilter,
157
256
  dryRun: true,
158
257
  maxFiles,
159
258
  failOnParseError: false,
@@ -256,7 +355,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
256
355
  count: fileReplacementCounts.get(filePath) ?? 0,
257
356
  }));
258
357
  if (result.limitReached) {
259
- outputLines.push("", "Limit reached; narrow path.");
358
+ outputLines.push("", "Limit reached; narrow paths.");
260
359
  }
261
360
  if (dedupedParseErrors.length) {
262
361
  outputLines.push("", ...formatParseErrors(dedupedParseErrors));
@@ -270,10 +369,8 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
270
369
  label: `AST Edit: ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}`,
271
370
  sourceToolName: this.name,
272
371
  apply: async (_reason: string) => {
273
- const applyResult = await astEdit({
372
+ const applyResult = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
274
373
  rewrites: normalizedRewrites,
275
- path: resolvedSearchPath,
276
- glob: globFilter,
277
374
  dryRun: false,
278
375
  maxFiles,
279
376
  failOnParseError: false,
@@ -349,7 +446,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
349
446
 
350
447
  interface AstEditRenderArgs {
351
448
  ops?: Array<{ pat?: string; out?: string }>;
352
- path?: string;
449
+ paths?: string[];
353
450
  }
354
451
 
355
452
  const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -358,7 +455,7 @@ export const astEditToolRenderer = {
358
455
  inline: true,
359
456
  renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
360
457
  const meta: string[] = [];
361
- if (args.path) meta.push(`in ${args.path}`);
458
+ if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
362
459
  const rewriteCount = args.ops?.length ?? 0;
363
460
  if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
364
461