@oh-my-pi/pi-coding-agent 14.5.13 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.5.14] - 2026-05-01
6
+ ### Changed
7
+
8
+ - Changed markdown conversion and archive tooling to defer loading heavy dependencies (Turndown, fflate, and browser agent content) until first use, reducing startup overhead for CLI startup and command initialization
9
+
10
+ ### Fixed
11
+
12
+ - Fixed changelog state tracking by flushing `lastChangelogVersion` to settings immediately when showing new entries, so the updated version is persisted across restarts
13
+
5
14
  ## [14.5.13] - 2026-05-01
6
15
 
7
16
  ### Breaking Changes
@@ -34,6 +43,10 @@
34
43
 
35
44
  - Fixed eval startup messaging to report `eval` as unavailable when Python is unreachable and JavaScript backend is disabled
36
45
 
46
+ ### Fixed
47
+ - Stabilized MCP tool ordering so reconnects and refreshes no longer reorder the tools array sent to the model. Anthropic prompt caching is keyed on byte-identical tool definitions; previously, the order depended on connection sequence and a single MCP server reconnect could shuffle tools across servers and invalidate the tools cache breakpoint.
48
+ - Skipped redundant system-prompt rebuilds in `AgentSession.refreshMCPTools` when the active tool set is unchanged. MCP transport flapping (e.g. routine 5-minute SSE reconnects) used to call `rebuildSystemPrompt` on every reconnect even though the resulting prompt was byte-identical, eating CPU and risking cache misses if the rebuild ever became non-deterministic. The applied-tool signature also covers `customWireName` so a wire-name flip with the rest of the tool metadata constant still forces a rebuild.
49
+
37
50
  ## [14.5.12] - 2026-04-30
38
51
 
39
52
  ### Breaking Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.5.13",
4
+ "version": "14.5.14",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.5.13",
50
- "@oh-my-pi/pi-agent-core": "14.5.13",
51
- "@oh-my-pi/pi-ai": "14.5.13",
52
- "@oh-my-pi/pi-natives": "14.5.13",
53
- "@oh-my-pi/pi-tui": "14.5.13",
54
- "@oh-my-pi/pi-utils": "14.5.13",
49
+ "@oh-my-pi/omp-stats": "14.5.14",
50
+ "@oh-my-pi/pi-agent-core": "14.5.14",
51
+ "@oh-my-pi/pi-ai": "14.5.14",
52
+ "@oh-my-pi/pi-natives": "14.5.14",
53
+ "@oh-my-pi/pi-tui": "14.5.14",
54
+ "@oh-my-pi/pi-utils": "14.5.14",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -25,7 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
25
25
 
26
26
  const SUMMARY_MAX_CHARS = 72;
27
27
  const RECENT_COMMITS_COUNT = 8;
28
- const TYPES_DESCRIPTION = prompt.render(typesDescriptionPrompt);
28
+ let _typesDescription: string | undefined;
29
+ const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt));
29
30
 
30
31
  /**
31
32
  * Execute the omp commit pipeline for staged changes.
@@ -176,7 +177,7 @@ async function generateAnalysis(input: {
176
177
  diff: input.diff,
177
178
  stat: input.stat,
178
179
  scopeCandidates: input.scopeCandidates,
179
- typesDescription: TYPES_DESCRIPTION,
180
+ typesDescription: TYPES_DESCRIPTION(),
180
181
  settings: {
181
182
  enabled: input.commitSettings.mapReduceEnabled,
182
183
  minFiles: input.commitSettings.mapReduceMinFiles,
@@ -193,7 +194,7 @@ async function generateAnalysis(input: {
193
194
  thinkingLevel: input.primaryThinkingLevel,
194
195
  contextFiles: input.contextFiles,
195
196
  userContext: input.userContext,
196
- typesDescription: TYPES_DESCRIPTION,
197
+ typesDescription: TYPES_DESCRIPTION(),
197
198
  recentCommits: input.recentCommits,
198
199
  scopeCandidates: input.scopeCandidates,
199
200
  stat: input.stat,
package/src/config.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from "@oh-my-pi/pi-utils";
12
12
  import type { TSchema } from "@sinclair/typebox";
13
13
  import { Value } from "@sinclair/typebox/value";
14
- import { Ajv, type ErrorObject, type ValidateFunction } from "ajv";
14
+ import type { ErrorObject } from "ajv";
15
15
  import { JSONC, YAML } from "bun";
16
16
  import { expandTilde } from "./tools/path-utils";
17
17
 
@@ -143,7 +143,6 @@ export type LoadResult<T> =
143
143
  | { value: T; error?: undefined; status: "ok" }
144
144
  | { value?: null; error?: unknown; status: "not-found" };
145
145
 
146
- const ajv = new Ajv();
147
146
  export class ConfigFile<T> implements IConfigFile<T> {
148
147
  readonly #basePath: string;
149
148
  #cache?: LoadResult<T>;
@@ -221,13 +220,17 @@ export class ConfigFile<T> implements IConfigFile<T> {
221
220
  throw new Error(`Invalid config file path: ${this.#basePath}`);
222
221
  }
223
222
 
224
- const validate = ajv.compile(this.schema) as ValidateFunction<T>;
225
- if (!validate(parsed)) {
226
- const error = new ConfigError(this.id, validate.errors);
223
+ if (!Value.Check(this.schema, parsed)) {
224
+ const schemaErrors: ErrorObject[] = [];
225
+ for (const err of Value.Errors(this.schema, parsed)) {
226
+ schemaErrors.push({ instancePath: err.path, message: err.message } as ErrorObject);
227
+ if (schemaErrors.length >= 50) break;
228
+ }
229
+ const error = new ConfigError(this.id, schemaErrors);
227
230
  logger.warn("Failed to parse config file", { path: this.path(), error });
228
231
  return this.#storeCache({ error, status: "error" });
229
232
  }
230
- return this.#storeCache({ value: parsed, status: "ok" });
233
+ return this.#storeCache({ value: parsed as T, status: "ok" });
231
234
  } catch (error) {
232
235
  if (isEnoent(error)) {
233
236
  return this.#storeCache({ status: "not-found" });
@@ -271,10 +271,10 @@ function normalizeDisplayText(text: string): string {
271
271
  }
272
272
 
273
273
  /** Renders a Jupyter display_data message into text and structured outputs. */
274
- export function renderKernelDisplay(content: Record<string, unknown>): {
274
+ export async function renderKernelDisplay(content: Record<string, unknown>): Promise<{
275
275
  text: string;
276
276
  outputs: KernelDisplayOutput[];
277
- } {
277
+ }> {
278
278
  const data = content.data as Record<string, unknown> | undefined;
279
279
  if (!data) return { text: "", outputs: [] };
280
280
 
@@ -307,7 +307,7 @@ export function renderKernelDisplay(content: Record<string, unknown>): {
307
307
  return { text: normalizeDisplayText(String(data["text/plain"])), outputs };
308
308
  }
309
309
  if (data["text/html"] !== undefined) {
310
- const markdown = htmlToBasicMarkdown(String(data["text/html"])) || "";
310
+ const markdown = (await htmlToBasicMarkdown(String(data["text/html"]))) || "";
311
311
  return { text: markdown ? normalizeDisplayText(markdown) : "", outputs };
312
312
  }
313
313
  return { text: "", outputs };
@@ -872,7 +872,7 @@ export class PythonKernel {
872
872
  }
873
873
  case "execute_result":
874
874
  case "display_data": {
875
- const { text, outputs } = renderKernelDisplay(response.content);
875
+ const { text, outputs } = await renderKernelDisplay(response.content);
876
876
  if (text && options?.onChunk) {
877
877
  await options.onChunk(text);
878
878
  }
package/src/main.ts CHANGED
@@ -253,12 +253,14 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
253
253
  if (!lastVersion) {
254
254
  if (entries.length > 0) {
255
255
  settings.set("lastChangelogVersion", VERSION);
256
+ await flushChangelogVersion();
256
257
  return entries.map(e => e.content).join("\n\n");
257
258
  }
258
259
  } else {
259
260
  const newEntries = getNewEntries(entries, lastVersion);
260
261
  if (newEntries.length > 0) {
261
262
  settings.set("lastChangelogVersion", VERSION);
263
+ await flushChangelogVersion();
262
264
  return newEntries.map(e => e.content).join("\n\n");
263
265
  }
264
266
  }
@@ -266,6 +268,14 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
266
268
  return undefined;
267
269
  }
268
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
+
269
279
  async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
270
280
  if (parsed.fork) {
271
281
  if (parsed.noSession) {
@@ -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 {
@@ -11,6 +11,7 @@ import type { SessionStats } from "../../session/agent-session";
11
11
  import type { CompactionResult } from "../../session/compaction";
12
12
  import type {
13
13
  RpcCommand,
14
+ RpcHandoffResult,
14
15
  RpcHostToolCallRequest,
15
16
  RpcHostToolCancelRequest,
16
17
  RpcHostToolDefinition,
@@ -457,6 +458,14 @@ export class RpcClient {
457
458
  return this.#getData(response);
458
459
  }
459
460
 
461
+ /**
462
+ * Hand off session context to a new session.
463
+ */
464
+ async handoff(customInstructions?: string): Promise<RpcHandoffResult | null> {
465
+ const response = await this.#send({ type: "handoff", customInstructions });
466
+ return this.#getData(response);
467
+ }
468
+
460
469
  /**
461
470
  * Export session to HTML.
462
471
  */
@@ -574,6 +574,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
574
574
  description: tool.description,
575
575
  parameters: tool.parameters,
576
576
  })),
577
+ contextUsage: session.getContextUsage(),
577
578
  };
578
579
  return success(id, "get_state", state);
579
580
  }
@@ -741,6 +742,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
741
742
  return success(id, "set_session_name");
742
743
  }
743
744
 
745
+ case "handoff": {
746
+ const result = await session.handoff(command.customInstructions);
747
+ return success(id, "handoff", result ? { savedPath: result.savedPath } : null);
748
+ }
749
+
744
750
  // =================================================================
745
751
  // Messages
746
752
  // =================================================================
@@ -7,6 +7,7 @@
7
7
  import type { AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
8
  import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai";
9
9
  import type { BashResult } from "../../exec/bash-executor";
10
+ import type { ContextUsage } from "../../extensibility/extensions/types";
10
11
  import type { SessionStats } from "../../session/agent-session";
11
12
  import type { CompactionResult } from "../../session/compaction";
12
13
  import type { TodoPhase } from "../../tools/todo-write";
@@ -63,6 +64,7 @@ export type RpcCommand =
63
64
  | { id?: string; type: "get_branch_messages" }
64
65
  | { id?: string; type: "get_last_assistant_text" }
65
66
  | { id?: string; type: "set_session_name"; name: string }
67
+ | { id?: string; type: "handoff"; customInstructions?: string }
66
68
 
67
69
  // Messages
68
70
  | { id?: string; type: "get_messages" };
@@ -89,6 +91,12 @@ export interface RpcSessionState {
89
91
  /** For session dump / export (plain-text parity with /dump). */
90
92
  systemPrompt?: string;
91
93
  dumpTools?: Array<{ name: string; description: string; parameters: unknown }>;
94
+ /** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */
95
+ contextUsage?: ContextUsage;
96
+ }
97
+
98
+ export interface RpcHandoffResult {
99
+ savedPath?: string;
92
100
  }
93
101
 
94
102
  // ============================================================================
@@ -180,6 +188,7 @@ export type RpcResponse =
180
188
  data: { text: string | null };
181
189
  }
182
190
  | { id?: string; type: "response"; command: "set_session_name"; success: true }
191
+ | { id?: string; type: "response"; command: "handoff"; success: true; data: RpcHandoffResult | null }
183
192
 
184
193
  // Messages
185
194
  | { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }
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();
@@ -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
  }
@@ -26,13 +26,17 @@ function normalize(text: string | null | undefined): string | undefined {
26
26
  * CSS selector chain over the same pre-parsed DOM. Returns null if neither
27
27
  * path yields usable content.
28
28
  */
29
- export function extractReadableFromHtml(html: string, url: string, format: ReadableFormat): ReadableResult | null {
29
+ export async function extractReadableFromHtml(
30
+ html: string,
31
+ url: string,
32
+ format: ReadableFormat,
33
+ ): Promise<ReadableResult | null> {
30
34
  const { document } = parseHTML(html);
31
35
 
32
36
  // --- Primary: Readability article extraction ---
33
37
  const article = new Readability(document).parse();
34
38
  if (article) {
35
- const result = toReadableResult(url, format, article.textContent, article.content, {
39
+ const result = await toReadableResult(url, format, article.textContent, article.content, {
36
40
  title: article.title,
37
41
  byline: article.byline,
38
42
  excerpt: article.excerpt,
@@ -55,7 +59,7 @@ export function extractReadableFromHtml(html: string, url: string, format: Reada
55
59
  const innerHTML = el.innerHTML?.trim();
56
60
  const textContent = el.textContent?.trim();
57
61
  if (!innerHTML || !textContent) continue;
58
- const result = toReadableResult(url, format, textContent, innerHTML, {
62
+ const result = await toReadableResult(url, format, textContent, innerHTML, {
59
63
  title: document.title,
60
64
  excerpt: textContent.slice(0, 240),
61
65
  length: textContent.length,
@@ -67,15 +71,16 @@ export function extractReadableFromHtml(html: string, url: string, format: Reada
67
71
  }
68
72
 
69
73
  /** Shared builder for both extraction paths. */
70
- function toReadableResult(
74
+ async function toReadableResult(
71
75
  url: string,
72
76
  format: ReadableFormat,
73
77
  textContent: string | null | undefined,
74
78
  htmlContent: string | null | undefined,
75
79
  meta: { title?: string | null; byline?: string | null; excerpt?: string | null; length?: number | null },
76
- ): ReadableResult | null {
80
+ ): Promise<ReadableResult | null> {
77
81
  const text = normalize(textContent);
78
- const markdown = format === "markdown" ? (normalize(htmlToBasicMarkdown(htmlContent ?? "")) ?? text) : undefined;
82
+ const markdown =
83
+ format === "markdown" ? (normalize(await htmlToBasicMarkdown(htmlContent ?? "")) ?? text) : undefined;
79
84
  const normalizedText = format === "text" ? text : undefined;
80
85
  if (!normalizedText && !markdown) return null;
81
86
  return {
@@ -16,7 +16,6 @@ import type {
16
16
  WorkerInitPayload,
17
17
  WorkerOutbound,
18
18
  } from "./tab-protocol";
19
- import { WorkerCore } from "./tab-worker";
20
19
 
21
20
  interface WorkerHandle {
22
21
  send(msg: WorkerInbound, transferList?: Transferable[]): void;
@@ -398,7 +397,7 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
398
397
  * entry. This preserves normal browser behavior but cannot interrupt synchronous
399
398
  * infinite loops because user code runs on the main thread.
400
399
  */
401
- function spawnInlineWorker(): WorkerHandle {
400
+ async function spawnInlineWorker(): Promise<WorkerHandle> {
402
401
  const hostListeners = new Set<(message: WorkerOutbound) => void>();
403
402
  const workerListeners = new Set<(message: WorkerInbound) => void>();
404
403
  const workerTransport: Transport = {
@@ -413,6 +412,7 @@ function spawnInlineWorker(): WorkerHandle {
413
412
  },
414
413
  close: () => {},
415
414
  };
415
+ const { WorkerCore } = await import("./tab-worker");
416
416
  new WorkerCore(workerTransport);
417
417
  return {
418
418
  mode: "inline",
@@ -110,12 +110,14 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
110
110
  export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
111
111
  readonly name = "browser";
112
112
  readonly label = "Browser";
113
- readonly description: string;
114
113
  readonly parameters = browserSchema;
115
114
  readonly strict = true;
116
115
 
117
- constructor(private readonly session: ToolSession) {
118
- this.description = prompt.render(browserDescription, {});
116
+ constructor(private readonly session: ToolSession) {}
117
+ #description?: string;
118
+ get description(): string {
119
+ this.#description ??= prompt.render(browserDescription, {});
120
+ return this.#description;
119
121
  }
120
122
 
121
123
  /** Restart browser to apply mode changes (e.g. headless toggle). Drops only headless browsers. */
@@ -1,6 +1,6 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import { getAntigravityHeaders, getEnvApiKey, type Model, StringEnum } from "@oh-my-pi/pi-ai";
3
+ import { getAntigravityUserAgent, getEnvApiKey, type Model, StringEnum } from "@oh-my-pi/pi-ai";
4
4
  import {
5
5
  CODEX_BASE_URL,
6
6
  getCodexAccountId,
@@ -1055,7 +1055,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1055
1055
  Authorization: `Bearer ${apiKey.apiKey}`,
1056
1056
  "Content-Type": "application/json",
1057
1057
  Accept: "text/event-stream",
1058
- ...getAntigravityHeaders(),
1058
+ "User-Agent": getAntigravityUserAgent(),
1059
1059
  },
1060
1060
  body: JSON.stringify(requestBody),
1061
1061
  signal: requestSignal,
@@ -6,7 +6,6 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
- import { unzipSync, zipSync } from "fflate";
10
9
  import { stripHashlinePrefixes } from "../edit";
11
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
11
  import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
@@ -44,6 +43,12 @@ import {
44
43
  import { ToolError } from "./tool-errors";
45
44
  import { toolResult } from "./tool-result";
46
45
 
46
+ let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
47
+ async function loadFflate(): Promise<typeof import("fflate")> {
48
+ if (!fflateModulePromise) fflateModulePromise = import("fflate");
49
+ return fflateModulePromise;
50
+ }
51
+
47
52
  const writeSchema = Type.Object({
48
53
  path: Type.String({ description: "file path", examples: ["src/new.ts"] }),
49
54
  content: Type.String({ description: "file content" }),
@@ -229,6 +234,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
229
234
  if (resolvedArchivePath.exists) {
230
235
  try {
231
236
  const bytes = await Bun.file(resolvedArchivePath.absolutePath).bytes();
237
+ const { unzipSync } = await loadFflate();
232
238
  const existing = unzipSync(new Uint8Array(bytes));
233
239
  for (const [entryPath, data] of Object.entries(existing)) {
234
240
  zipEntries[entryPath.replace(/\\/g, "/")] = data;
@@ -241,6 +247,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
241
247
  zipEntries[resolvedArchivePath.archiveSubPath] = new TextEncoder().encode(content);
242
248
 
243
249
  try {
250
+ const { zipSync } = await loadFflate();
244
251
  const zipBuffer = zipSync(zipEntries);
245
252
  await Bun.write(resolvedArchivePath.absolutePath, zipBuffer);
246
253
  } catch (error) {
@@ -66,10 +66,10 @@ function formatDate(date?: CrossrefDate): string | null {
66
66
  return formatted.join("-");
67
67
  }
68
68
 
69
- function formatAbstract(abstract?: string): string | null {
69
+ async function formatAbstract(abstract?: string): Promise<string | null> {
70
70
  if (!abstract) return null;
71
71
  const normalized = abstract.replace(/<\/?jats:p[^>]*>/g, match => (match.startsWith("</") ? "</p>" : "<p>"));
72
- const markdown = htmlToBasicMarkdown(normalized);
72
+ const markdown = await htmlToBasicMarkdown(normalized);
73
73
  return markdown.trim().length > 0 ? markdown : null;
74
74
  }
75
75
 
@@ -114,7 +114,7 @@ export const handleCrossref: SpecialHandler = async (
114
114
  formatDate(message.issued) ||
115
115
  formatDate(message.created);
116
116
  const doiValue = message.DOI || doi;
117
- const abstract = formatAbstract(message.abstract);
117
+ const abstract = await formatAbstract(message.abstract);
118
118
  const type = message.type?.replace(/-/g, " ");
119
119
 
120
120
  let md = `# ${title}\n\n`;
@@ -133,7 +133,7 @@ export const handleDevTo: SpecialHandler = async (
133
133
  if (article.body_markdown) {
134
134
  md += article.body_markdown;
135
135
  } else if (article.body_html) {
136
- md += htmlToBasicMarkdown(article.body_html);
136
+ md += await htmlToBasicMarkdown(article.body_html);
137
137
  }
138
138
 
139
139
  notes.push("Fetched via dev.to API");
@@ -77,12 +77,12 @@ function formatCategory(topic: DiscourseTopic): string | null {
77
77
  return parts.length ? parts.join(" ") : null;
78
78
  }
79
79
 
80
- function formatPostBody(post: DiscoursePost): string {
80
+ async function formatPostBody(post: DiscoursePost): Promise<string> {
81
81
  const raw = post.raw?.trim();
82
82
  if (raw) return raw;
83
83
  const cooked = post.cooked?.trim();
84
84
  if (!cooked) return "";
85
- return htmlToBasicMarkdown(cooked);
85
+ return await htmlToBasicMarkdown(cooked);
86
86
  }
87
87
 
88
88
  function buildTopicUrl(baseUrl: string, topicId: string): string {
@@ -168,9 +168,9 @@ export const handleDiscourse: SpecialHandler = async (
168
168
  md += "\n";
169
169
 
170
170
  const description = topic.excerpt
171
- ? htmlToBasicMarkdown(topic.excerpt)
171
+ ? await htmlToBasicMarkdown(topic.excerpt)
172
172
  : posts.length
173
- ? formatPostBody(posts[0])
173
+ ? await formatPostBody(posts[0])
174
174
  : "";
175
175
  if (description) {
176
176
  md += `## Description\n\n${description}\n\n`;
@@ -182,7 +182,7 @@ export const handleDiscourse: SpecialHandler = async (
182
182
  const author = formatAuthor({ name: post.name, username: post.username });
183
183
  const date = formatIsoDate(post.created_at);
184
184
  const likes = post.like_count ?? 0;
185
- const content = formatPostBody(post);
185
+ const content = await formatPostBody(post);
186
186
  const postLabel = post.post_number != null ? `Post ${post.post_number}` : `Post ${post.id}`;
187
187
 
188
188
  md += `### ${postLabel} - ${author} - ${date} - Likes: ${likes}\n\n`;
@@ -112,7 +112,7 @@ export const handleFirefoxAddons: SpecialHandler = async (
112
112
  const name = getLocalizedText(data.name, defaultLocale) ?? slug;
113
113
  const summary = getLocalizedText(data.summary, defaultLocale);
114
114
  const descriptionRaw = getLocalizedText(data.description, defaultLocale);
115
- const description = descriptionRaw ? htmlToBasicMarkdown(descriptionRaw) : undefined;
115
+ const description = descriptionRaw ? await htmlToBasicMarkdown(descriptionRaw) : undefined;
116
116
 
117
117
  const authors = (data.authors ?? [])
118
118
  .map(author => author.name ?? "")
@@ -170,7 +170,7 @@ export const handleFlathub: SpecialHandler = async (
170
170
  }
171
171
 
172
172
  if (app.description) {
173
- const description = htmlToBasicMarkdown(app.description);
173
+ const description = await htmlToBasicMarkdown(app.description);
174
174
  if (description) md += `\n## Description\n\n${description}\n`;
175
175
  }
176
176
 
@@ -204,7 +204,7 @@ export const handleFlathub: SpecialHandler = async (
204
204
  md += `${line}\n`;
205
205
 
206
206
  if (release.description) {
207
- const releaseDesc = htmlToBasicMarkdown(release.description).replace(/\n+/g, " ").trim();
207
+ const releaseDesc = (await htmlToBasicMarkdown(release.description)).replace(/\n+/g, " ").trim();
208
208
  if (releaseDesc) md += ` - ${releaseDesc}\n`;
209
209
  }
210
210
  }
@@ -259,7 +259,7 @@ async function renderGitLabIssue(
259
259
  }
260
260
 
261
261
  md += `\n---\n\n## Description\n\n`;
262
- md += issue.description ? htmlToBasicMarkdown(issue.description) : "*No description*";
262
+ md += issue.description ? await htmlToBasicMarkdown(issue.description) : "*No description*";
263
263
 
264
264
  return { content: md, ok: true };
265
265
  }
@@ -159,7 +159,7 @@ export const handleGoPkg: SpecialHandler = async (
159
159
  // Get overview paragraph
160
160
  const overview = docSection.querySelector(".go-Message");
161
161
  if (overview) {
162
- const overviewMd = htmlToBasicMarkdown(overview.innerHTML);
162
+ const overviewMd = await htmlToBasicMarkdown(overview.innerHTML);
163
163
  sections.push(overviewMd);
164
164
  sections.push("");
165
165
  }
@@ -172,7 +172,7 @@ export const handleGoPkg: SpecialHandler = async (
172
172
  const docParts: string[] = [];
173
173
  for (let i = 0; i < Math.min(3, paragraphs.length); i++) {
174
174
  const p = paragraphs[i];
175
- const text = htmlToBasicMarkdown(p.innerHTML).trim();
175
+ const text = (await htmlToBasicMarkdown(p.innerHTML)).trim();
176
176
  if (text) {
177
177
  docParts.push(text);
178
178
  }
@@ -108,7 +108,7 @@ export const handleJetBrainsMarketplace: SpecialHandler = async (
108
108
 
109
109
  const vendorName = plugin.vendor?.name ?? plugin.vendor?.publicName;
110
110
  const descriptionSource = plugin.description ?? plugin.preview ?? "";
111
- const description = descriptionSource ? htmlToBasicMarkdown(descriptionSource) : "";
111
+ const description = descriptionSource ? await htmlToBasicMarkdown(descriptionSource) : "";
112
112
  const tags = (plugin.tags ?? []).map(tag => tag.name).filter(Boolean) as string[];
113
113
  const rating = extractRating(plugin);
114
114
  const buildCompatibility = update ? formatBuildCompatibility(update) : null;
@@ -89,11 +89,11 @@ function formatDate(isoDate: string): string {
89
89
  /**
90
90
  * Format a status/post as markdown
91
91
  */
92
- function formatStatus(status: MastodonStatus, isReblog = false): string {
92
+ async function formatStatus(status: MastodonStatus, isReblog = false): Promise<string> {
93
93
  // Handle reblogs (boosts)
94
94
  if (status.reblog && !isReblog) {
95
95
  let md = `🔁 **${status.account.display_name || status.account.username}** boosted:\n\n`;
96
- md += formatStatus(status.reblog, true);
96
+ md += await formatStatus(status.reblog, true);
97
97
  return md;
98
98
  }
99
99
 
@@ -116,7 +116,7 @@ function formatStatus(status: MastodonStatus, isReblog = false): string {
116
116
  }
117
117
 
118
118
  // Main content (convert HTML to markdown)
119
- const content = htmlToBasicMarkdown(status.content);
119
+ const content = await htmlToBasicMarkdown(status.content);
120
120
  md += `${content}\n\n`;
121
121
 
122
122
  // Poll
@@ -152,7 +152,7 @@ function formatStatus(status: MastodonStatus, isReblog = false): string {
152
152
  /**
153
153
  * Format an account/profile as markdown
154
154
  */
155
- function formatAccount(account: MastodonAccount): string {
155
+ async function formatAccount(account: MastodonAccount): Promise<string> {
156
156
  let md = `# ${account.display_name || account.username}\n\n`;
157
157
 
158
158
  md += `**@${account.acct}**`;
@@ -161,7 +161,7 @@ function formatAccount(account: MastodonAccount): string {
161
161
 
162
162
  // Bio
163
163
  if (account.note) {
164
- const bio = htmlToBasicMarkdown(account.note);
164
+ const bio = await htmlToBasicMarkdown(account.note);
165
165
  if (bio && bio !== account.display_name) {
166
166
  md += `${bio}\n\n`;
167
167
  }
@@ -179,7 +179,7 @@ function formatAccount(account: MastodonAccount): string {
179
179
  if (account.fields && account.fields.length > 0) {
180
180
  md += "\n**Profile Fields:**\n";
181
181
  for (const field of account.fields) {
182
- const value = htmlToBasicMarkdown(field.value);
182
+ const value = await htmlToBasicMarkdown(field.value);
183
183
  md += `- **${field.name}:** ${value}\n`;
184
184
  }
185
185
  }
@@ -228,7 +228,7 @@ export const handleMastodon: SpecialHandler = async (
228
228
  const status = tryParseJson<MastodonStatus>(result.content);
229
229
  if (!status) return null;
230
230
 
231
- const md = formatStatus(status);
231
+ const md = await formatStatus(status);
232
232
 
233
233
  return buildResult(md, {
234
234
  url,
@@ -263,7 +263,7 @@ export const handleMastodon: SpecialHandler = async (
263
263
  signal,
264
264
  });
265
265
 
266
- let md = formatAccount(account);
266
+ let md = await formatAccount(account);
267
267
 
268
268
  if (statusesResult.ok) {
269
269
  const statuses = tryParseJson<MastodonStatus[]>(statusesResult.content);
@@ -271,7 +271,7 @@ export const handleMastodon: SpecialHandler = async (
271
271
  md += "\n---\n\n## Recent Posts\n\n";
272
272
  for (const status of statuses.slice(0, 5)) {
273
273
  md += `### ${formatDate(status.created_at)}\n\n`;
274
- const content = htmlToBasicMarkdown(status.content);
274
+ const content = await htmlToBasicMarkdown(status.content);
275
275
  md += `${content}\n\n`;
276
276
  md += `\uD83D\uDCAC ${status.replies_count} \u00B7 \uD83D\uDD01 ${status.reblogs_count} \u00B7 \u2B50 ${status.favourites_count}\n\n`;
277
277
  }
@@ -29,7 +29,7 @@ interface MDNDoc {
29
29
  /**
30
30
  * Convert MDN body sections to markdown
31
31
  */
32
- function convertMDNBody(sections: MDNSection[]): string {
32
+ async function convertMDNBody(sections: MDNSection[]): Promise<string> {
33
33
  const parts: string[] = [];
34
34
 
35
35
  for (const section of sections) {
@@ -38,7 +38,7 @@ function convertMDNBody(sections: MDNSection[]): string {
38
38
  switch (type) {
39
39
  case "prose":
40
40
  if (value.content) {
41
- const markdown = htmlToBasicMarkdown(value.content);
41
+ const markdown = await htmlToBasicMarkdown(value.content);
42
42
  if (value.title) {
43
43
  const level = value.isH3 ? "###" : "##";
44
44
  parts.push(`${level} ${value.title}\n\n${markdown}`);
@@ -74,7 +74,7 @@ function convertMDNBody(sections: MDNSection[]): string {
74
74
  if (value.items) {
75
75
  for (const item of value.items) {
76
76
  parts.push(`**${item.term}**`);
77
- const desc = htmlToBasicMarkdown(item.description);
77
+ const desc = await htmlToBasicMarkdown(item.description);
78
78
  parts.push(desc);
79
79
  }
80
80
  }
@@ -83,9 +83,13 @@ function convertMDNBody(sections: MDNSection[]): string {
83
83
  case "table":
84
84
  if (value.rows && value.rows.length > 0) {
85
85
  // Simple markdown table
86
- const header = value.rows[0].map(cell => htmlToBasicMarkdown(cell)).join(" | ");
86
+ const header = (await Promise.all(value.rows[0].map(cell => htmlToBasicMarkdown(cell)))).join(" | ");
87
87
  const separator = value.rows[0].map(() => "---").join(" | ");
88
- const bodyRows = value.rows.slice(1).map(row => row.map(cell => htmlToBasicMarkdown(cell)).join(" | "));
88
+ const bodyRows = await Promise.all(
89
+ value.rows
90
+ .slice(1)
91
+ .map(async row => (await Promise.all(row.map(cell => htmlToBasicMarkdown(cell)))).join(" | ")),
92
+ );
89
93
 
90
94
  parts.push(`| ${header} |`);
91
95
  parts.push(`| ${separator} |`);
@@ -144,12 +148,12 @@ export const handleMDN: SpecialHandler = async (url: string, timeout: number, si
144
148
  parts.push(`# ${doc.title}`);
145
149
 
146
150
  if (doc.summary) {
147
- const summary = htmlToBasicMarkdown(doc.summary);
151
+ const summary = await htmlToBasicMarkdown(doc.summary);
148
152
  parts.push(summary);
149
153
  }
150
154
 
151
155
  if (doc.body && doc.body.length > 0) {
152
- const bodyMarkdown = convertMDNBody(doc.body);
156
+ const bodyMarkdown = await convertMDNBody(doc.body);
153
157
  parts.push(bodyMarkdown);
154
158
  }
155
159
 
@@ -125,7 +125,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number,
125
125
  /<div[^>]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
126
126
  );
127
127
  if (readmeMatch) {
128
- const readme = htmlToBasicMarkdown(readmeMatch[1]);
128
+ const readme = await htmlToBasicMarkdown(readmeMatch[1]);
129
129
 
130
130
  if (readme.length > 100) {
131
131
  md += `## README\n\n${readme}\n`;
@@ -63,7 +63,7 @@ export const handleRawg: SpecialHandler = async (
63
63
  md += `**RAWG:** https://rawg.io/games/${encodeURIComponent(slug)}\n`;
64
64
  md += "\n";
65
65
 
66
- const description = extractDescription(game);
66
+ const description = await extractDescription(game);
67
67
  if (description) {
68
68
  md += `## Description\n\n${description}\n`;
69
69
  }
@@ -91,11 +91,11 @@ function requiresApiKey(game: RawgGameResponse): boolean {
91
91
  return detail.includes("api key") || detail.includes("key is required") || detail.includes("apikey");
92
92
  }
93
93
 
94
- function extractDescription(game: RawgGameResponse): string | null {
94
+ async function extractDescription(game: RawgGameResponse): Promise<string | null> {
95
95
  if (game.description_raw) return game.description_raw.trim();
96
96
  if (!game.description) return null;
97
97
 
98
- const markdown = htmlToBasicMarkdown(game.description).trim();
98
+ const markdown = (await htmlToBasicMarkdown(game.description)).trim();
99
99
  return markdown || null;
100
100
  }
101
101
 
@@ -101,7 +101,7 @@ export const handleReadTheDocs: SpecialHandler = async (
101
101
  // If no raw source, convert HTML to markdown
102
102
  if (!content && mainContent) {
103
103
  const html = mainContent.innerHTML;
104
- content = htmlToBasicMarkdown(html);
104
+ content = await htmlToBasicMarkdown(html);
105
105
  }
106
106
 
107
107
  if (!content) {
@@ -94,7 +94,7 @@ export const handleSpdx: SpecialHandler = async (
94
94
  const licenseText = license.licenseText
95
95
  ? license.licenseText
96
96
  : license.licenseTextHtml
97
- ? htmlToBasicMarkdown(license.licenseTextHtml)
97
+ ? await htmlToBasicMarkdown(license.licenseTextHtml)
98
98
  : null;
99
99
 
100
100
  if (licenseText) {
@@ -90,7 +90,7 @@ export const handleStackOverflow: SpecialHandler = async (
90
90
  md += question.is_answered ? " (Answered)" : "";
91
91
  md += `\n**Tags:** ${question.tags.join(", ")}\n`;
92
92
  md += `**Asked by:** ${question.owner.display_name} · ${formatIsoDate(question.creation_date * 1000)}\n\n`;
93
- md += `---\n\n## Question\n\n${htmlToBasicMarkdown(question.body)}\n\n`;
93
+ md += `---\n\n## Question\n\n${await htmlToBasicMarkdown(question.body)}\n\n`;
94
94
 
95
95
  // Fetch answers
96
96
  const aUrl = `https://api.stackexchange.com/2.3/questions/${questionId}/answers?order=desc&sort=votes&site=${site}&filter=withbody`;
@@ -103,7 +103,7 @@ export const handleStackOverflow: SpecialHandler = async (
103
103
  for (const answer of aData.items.slice(0, 5)) {
104
104
  const accepted = answer.is_accepted ? " (Accepted)" : "";
105
105
  md += `### Score: ${answer.score}${accepted} · by ${answer.owner.display_name}\n\n`;
106
- md += `${htmlToBasicMarkdown(answer.body)}\n\n---\n\n`;
106
+ md += `${await htmlToBasicMarkdown(answer.body)}\n\n---\n\n`;
107
107
  }
108
108
  }
109
109
  }
@@ -2,8 +2,8 @@
2
2
  * Shared types and utilities for web-fetch handlers
3
3
  */
4
4
  import { ptree } from "@oh-my-pi/pi-utils";
5
- import TurndownService from "turndown";
6
- import { gfm } from "turndown-plugin-gfm";
5
+ import type TurndownService from "turndown";
6
+
7
7
  import { ToolAbortError } from "../../tools/tool-errors";
8
8
 
9
9
  export { formatNumber } from "@oh-my-pi/pi-utils";
@@ -155,28 +155,8 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
155
155
  return { content: "", contentType: "", finalUrl: url, ok: false };
156
156
  }
157
157
 
158
- /** Module-level Turndown instance — matches markit-ai's configuration. */
159
- const turndown = new TurndownService({
160
- headingStyle: "atx",
161
- codeBlockStyle: "fenced",
162
- bulletListMarker: "-",
163
- });
164
- turndown.use(gfm);
165
- turndown.addRule("strikethrough", {
166
- filter: ["del", "s", "strike"],
167
- replacement(content) {
168
- return `~~${content}~~`;
169
- },
170
- });
171
- turndown.addRule("heading", {
172
- filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
173
- replacement(content, node) {
174
- const level = Number(node.nodeName.charAt(1));
175
- const prefix = "#".repeat(level);
176
- const cleaned = content.replace(/\\([.])/g, "$1").trim();
177
- return `\n\n${prefix} ${cleaned}\n\n`;
178
- },
179
- });
158
+ /** Module-level Turndown instance — built lazily on first use. */
159
+ let turndownPromise: Promise<TurndownService> | undefined;
180
160
 
181
161
  type TurndownListParent = {
182
162
  nodeName: string;
@@ -184,27 +164,61 @@ type TurndownListParent = {
184
164
  children: ArrayLike<unknown>;
185
165
  };
186
166
 
187
- turndown.addRule("listItem", {
188
- filter: "li",
189
- replacement(content, node, options) {
190
- content = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n ");
191
- const parent = node.parentNode as unknown as TurndownListParent | null;
192
- let prefix = `${options.bulletListMarker} `;
193
- if (parent?.nodeName === "OL") {
194
- const start = parent.getAttribute("start");
195
- const index = Array.prototype.indexOf.call(parent.children, node);
196
- prefix = `${(start ? Number(start) : 1) + index}. `;
197
- }
198
- return prefix + content + (node.nextSibling ? "\n" : "");
199
- },
200
- });
167
+ function getTurndown(): Promise<TurndownService> {
168
+ turndownPromise ||= initTurndown();
169
+ return turndownPromise;
170
+ }
171
+
172
+ async function initTurndown(): Promise<TurndownService> {
173
+ const [{ default: TurndownService }, { gfm }] = await Promise.all([
174
+ import("turndown"),
175
+ import("turndown-plugin-gfm"),
176
+ ]);
177
+ const turndown = new TurndownService({
178
+ headingStyle: "atx",
179
+ codeBlockStyle: "fenced",
180
+ bulletListMarker: "-",
181
+ });
182
+ turndown.use(gfm);
183
+ turndown.addRule("strikethrough", {
184
+ filter: ["del", "s", "strike"],
185
+ replacement(content) {
186
+ return `~~${content}~~`;
187
+ },
188
+ });
189
+ turndown.addRule("heading", {
190
+ filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
191
+ replacement(content, node) {
192
+ const level = Number(node.nodeName.charAt(1));
193
+ const prefix = "#".repeat(level);
194
+ const cleaned = content.replace(/\\([.])/g, "$1").trim();
195
+ return `\n\n${prefix} ${cleaned}\n\n`;
196
+ },
197
+ });
198
+ turndown.addRule("listItem", {
199
+ filter: "li",
200
+ replacement(content, node, options) {
201
+ content = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n ");
202
+ const parent = node.parentNode as unknown as TurndownListParent | null;
203
+ let prefix = `${options.bulletListMarker} `;
204
+ if (parent?.nodeName === "OL") {
205
+ const start = parent.getAttribute("start");
206
+ const index = Array.prototype.indexOf.call(parent.children, node);
207
+ prefix = `${(start ? Number(start) : 1) + index}. `;
208
+ }
209
+ return prefix + content + (node.nextSibling ? "\n" : "");
210
+ },
211
+ });
212
+ return turndown;
213
+ }
201
214
 
202
215
  /**
203
216
  * Convert HTML to markdown using Turndown with GFM support.
204
217
  * Strips script/style tags before conversion.
205
218
  */
206
- export function htmlToBasicMarkdown(html: string): string {
219
+ export async function htmlToBasicMarkdown(html: string): Promise<string> {
207
220
  const cleaned = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
221
+ const turndown = await getTurndown();
208
222
  return turndown.turndown(cleaned).trim();
209
223
  }
210
224
 
@@ -100,7 +100,7 @@ export const handleW3c: SpecialHandler = async (
100
100
  const title = getString(specPayload, "title");
101
101
  const shortnameValue = getString(specPayload, "shortname") ?? shortname;
102
102
  const description = getString(specPayload, "description") ?? getString(specPayload, "abstract");
103
- const abstract = description ? htmlToBasicMarkdown(description) : undefined;
103
+ const abstract = description ? await htmlToBasicMarkdown(description) : undefined;
104
104
 
105
105
  const latestVersionUrl =
106
106
  getString(latestPayload, "uri") ??
@@ -8,7 +8,7 @@
8
8
  import {
9
9
  ANTIGRAVITY_SYSTEM_INSTRUCTION,
10
10
  extractRetryDelay,
11
- getAntigravityHeaders,
11
+ getAntigravityUserAgent,
12
12
  getGeminiCliHeaders,
13
13
  } from "@oh-my-pi/pi-ai";
14
14
  import { refreshAntigravityToken } from "@oh-my-pi/pi-ai/utils/oauth/google-antigravity";
@@ -248,7 +248,7 @@ async function callGeminiSearch(
248
248
  usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
249
249
  }> {
250
250
  const endpoints = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
251
- const headers = auth.isAntigravity ? getAntigravityHeaders() : getGeminiCliHeaders();
251
+ const headers = auth.isAntigravity ? { "User-Agent": getAntigravityUserAgent() } : getGeminiCliHeaders();
252
252
 
253
253
  const requestMetadata = auth.isAntigravity
254
254
  ? {