@oh-my-pi/pi-coding-agent 13.14.2 → 13.15.3

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 (85) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +423 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/export/html/template.css +43 -13
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.html +1 -0
  27. package/src/export/html/template.js +107 -0
  28. package/src/extensibility/extensions/types.ts +31 -8
  29. package/src/internal-urls/docs-index.generated.ts +1 -1
  30. package/src/lsp/index.ts +1 -1
  31. package/src/main.ts +44 -44
  32. package/src/mcp/oauth-discovery.ts +1 -1
  33. package/src/modes/acp/acp-agent.ts +957 -0
  34. package/src/modes/acp/acp-event-mapper.ts +531 -0
  35. package/src/modes/acp/acp-mode.ts +13 -0
  36. package/src/modes/acp/index.ts +2 -0
  37. package/src/modes/components/agent-dashboard.ts +5 -4
  38. package/src/modes/components/custom-editor.ts +53 -51
  39. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  40. package/src/modes/components/history-search.ts +2 -1
  41. package/src/modes/components/hook-editor.ts +2 -1
  42. package/src/modes/components/hook-input.ts +8 -7
  43. package/src/modes/components/hook-selector.ts +15 -10
  44. package/src/modes/components/keybinding-hints.ts +9 -9
  45. package/src/modes/components/login-dialog.ts +3 -3
  46. package/src/modes/components/mcp-add-wizard.ts +2 -1
  47. package/src/modes/components/model-selector.ts +14 -3
  48. package/src/modes/components/oauth-selector.ts +2 -1
  49. package/src/modes/components/session-selector.ts +2 -1
  50. package/src/modes/components/settings-selector.ts +2 -1
  51. package/src/modes/components/status-line-segment-editor.ts +2 -1
  52. package/src/modes/components/tree-selector.ts +3 -2
  53. package/src/modes/components/user-message-selector.ts +3 -8
  54. package/src/modes/components/user-message.ts +16 -0
  55. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  56. package/src/modes/controllers/input-controller.ts +48 -29
  57. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  58. package/src/modes/index.ts +1 -0
  59. package/src/modes/interactive-mode.ts +17 -5
  60. package/src/modes/print-mode.ts +1 -1
  61. package/src/modes/prompt-action-autocomplete.ts +7 -7
  62. package/src/modes/rpc/rpc-mode.ts +7 -2
  63. package/src/modes/rpc/rpc-types.ts +1 -0
  64. package/src/modes/theme/theme.ts +53 -44
  65. package/src/modes/types.ts +9 -2
  66. package/src/modes/utils/hotkeys-markdown.ts +20 -20
  67. package/src/modes/utils/keybinding-matchers.ts +21 -0
  68. package/src/modes/utils/ui-helpers.ts +1 -1
  69. package/src/patch/hashline.ts +139 -127
  70. package/src/patch/index.ts +77 -59
  71. package/src/patch/shared.ts +19 -11
  72. package/src/prompts/tools/hashline.md +43 -116
  73. package/src/sdk.ts +34 -17
  74. package/src/session/agent-session.ts +436 -86
  75. package/src/session/messages.ts +23 -0
  76. package/src/session/session-manager.ts +97 -31
  77. package/src/tools/ask.ts +56 -30
  78. package/src/tools/bash-interceptor.ts +1 -39
  79. package/src/tools/bash-skill-urls.ts +1 -1
  80. package/src/tools/browser.ts +1 -1
  81. package/src/tools/gemini-image.ts +1 -1
  82. package/src/tools/resolve.ts +1 -1
  83. package/src/utils/child-process.ts +88 -0
  84. package/src/utils/image-input.ts +11 -1
  85. package/src/web/search/providers/codex.ts +10 -3
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
8
  import type {
9
+ AssistantMessage,
9
10
  ImageContent,
10
11
  Message,
11
12
  MessageAttribution,
@@ -213,6 +214,28 @@ export function createCompactionSummaryMessage(
213
214
  };
214
215
  }
215
216
 
217
+ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: AssistantMessage): AssistantMessage {
218
+ if (message.providerPayload?.type !== "openaiResponsesHistory") {
219
+ return message;
220
+ }
221
+
222
+ let didSanitize = false;
223
+ const sanitizedContent = message.content.map(block => {
224
+ if (block.type !== "thinking" || block.thinkingSignature === undefined) {
225
+ return block;
226
+ }
227
+
228
+ didSanitize = true;
229
+ return { ...block, thinkingSignature: undefined };
230
+ });
231
+
232
+ if (!didSanitize) {
233
+ return message;
234
+ }
235
+
236
+ return { ...message, content: sanitizedContent };
237
+ }
238
+
216
239
  /** Convert CustomMessageEntry to AgentMessage format */
217
240
  export function createCustomMessage(
218
241
  customType: string,
@@ -46,6 +46,7 @@ import {
46
46
  type FileMentionMessage,
47
47
  type HookMessage,
48
48
  type PythonExecutionMessage,
49
+ sanitizeRehydratedOpenAIResponsesAssistantMessage,
49
50
  } from "./messages";
50
51
  import type { SessionStorage, SessionStorageWriter } from "./session-storage";
51
52
  import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
@@ -1302,21 +1303,19 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
1302
1303
  }
1303
1304
  }
1304
1305
 
1305
- if (messageCount) {
1306
- const stats = storage.statSync(file);
1307
- sessions.push({
1308
- path: file,
1309
- id: header.id,
1310
- cwd: typeof header.cwd === "string" ? header.cwd : "",
1311
- title: header.title ?? shortSummary,
1312
- parentSessionPath: (header as SessionHeader).parentSession,
1313
- created: new Date(header.timestamp),
1314
- modified: stats.mtime,
1315
- messageCount,
1316
- firstMessage: firstMessage || "(no messages)",
1317
- allMessagesText: allMessages.join(" "),
1318
- });
1319
- }
1306
+ const stats = storage.statSync(file);
1307
+ sessions.push({
1308
+ path: file,
1309
+ id: header.id,
1310
+ cwd: typeof header.cwd === "string" ? header.cwd : "",
1311
+ title: header.title ?? shortSummary,
1312
+ parentSessionPath: (header as SessionHeader).parentSession,
1313
+ created: new Date(header.timestamp),
1314
+ modified: stats.mtime,
1315
+ messageCount,
1316
+ firstMessage: firstMessage || "(no messages)",
1317
+ allMessagesText: allMessages.join(" "),
1318
+ });
1320
1319
  } catch {}
1321
1320
  }),
1322
1321
  );
@@ -1376,11 +1375,21 @@ export async function resolveResumableSession(
1376
1375
 
1377
1376
  return { session: globalMatch, scope: "global" };
1378
1377
  }
1378
+ interface SessionManagerStateSnapshot {
1379
+ sessionId: string;
1380
+ sessionName: string | undefined;
1381
+ sessionFile: string | undefined;
1382
+ flushed: boolean;
1383
+ needsFullRewriteOnNextPersist: boolean;
1384
+ fileEntries: FileEntry[];
1385
+ }
1386
+
1379
1387
  export class SessionManager {
1380
1388
  #sessionId: string = "";
1381
1389
  #sessionName: string | undefined;
1382
1390
  #sessionFile: string | undefined;
1383
1391
  #flushed: boolean = false;
1392
+ #needsFullRewriteOnNextPersist: boolean = false;
1384
1393
  #fileEntries: FileEntry[] = [];
1385
1394
  #byId: Map<string, SessionEntry> = new Map();
1386
1395
  #labelsById: Map<string, string> = new Map();
@@ -1420,6 +1429,39 @@ export class SessionManager {
1420
1429
  return this.#blobStore.put(data);
1421
1430
  }
1422
1431
 
1432
+ captureState(): SessionManagerStateSnapshot {
1433
+ return {
1434
+ sessionId: this.#sessionId,
1435
+ sessionName: this.#sessionName,
1436
+ sessionFile: this.#sessionFile,
1437
+ flushed: this.#flushed,
1438
+ needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
1439
+ // Snapshot entry objects by reference: switch/reload replaces the active entry array,
1440
+ // so rollback does not need structured cloning of extension/custom details.
1441
+ fileEntries: [...this.#fileEntries],
1442
+ };
1443
+ }
1444
+
1445
+ restoreState(snapshot: SessionManagerStateSnapshot): void {
1446
+ this.#sessionId = snapshot.sessionId;
1447
+ this.#sessionName = snapshot.sessionName;
1448
+ this.#sessionFile = snapshot.sessionFile;
1449
+ this.#flushed = snapshot.flushed;
1450
+ this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
1451
+ this.#fileEntries = [...snapshot.fileEntries];
1452
+ this.#persistWriter = undefined;
1453
+ this.#persistWriterPath = undefined;
1454
+ this.#persistChain = Promise.resolve();
1455
+ this.#persistError = undefined;
1456
+ this.#persistErrorReported = false;
1457
+ this.#artifactManager = null;
1458
+ this.#artifactManagerSessionFile = null;
1459
+ this.#buildIndex();
1460
+ if (this.#sessionFile) {
1461
+ writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
1462
+ }
1463
+ }
1464
+
1423
1465
  /** Initialize with a specific session file (used by factory methods) */
1424
1466
  async #initSessionFile(sessionFile: string): Promise<void> {
1425
1467
  await this.setSessionFile(sessionFile);
@@ -1443,11 +1485,10 @@ export class SessionManager {
1443
1485
  this.#sessionId = header?.id ?? Snowflake.next();
1444
1486
  this.#sessionName = header?.title;
1445
1487
 
1446
- if (migrateToCurrentVersion(this.#fileEntries)) {
1447
- await this.#rewriteFile();
1448
- }
1488
+ this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
1449
1489
 
1450
1490
  await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
1491
+ this.sanitizeLoadedOpenAIResponsesReplayMetadata();
1451
1492
 
1452
1493
  this.#buildIndex();
1453
1494
  this.#flushed = true;
@@ -1632,6 +1673,7 @@ export class SessionManager {
1632
1673
  this.#labelsById.clear();
1633
1674
  this.#leafId = null;
1634
1675
  this.#flushed = false;
1676
+ this.#needsFullRewriteOnNextPersist = false;
1635
1677
  this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
1636
1678
 
1637
1679
  if (this.persist) {
@@ -1774,6 +1816,7 @@ export class SessionManager {
1774
1816
  this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
1775
1817
  );
1776
1818
  await this.#writeEntriesAtomically(entries);
1819
+ this.#needsFullRewriteOnNextPersist = false;
1777
1820
  this.#flushed = true;
1778
1821
  });
1779
1822
  }
@@ -1782,6 +1825,16 @@ export class SessionManager {
1782
1825
  return this.persist;
1783
1826
  }
1784
1827
 
1828
+ /**
1829
+ * Force-persist all current entries to disk, even when no assistant message exists yet.
1830
+ * Used by ACP mode where session/new must create a discoverable session immediately.
1831
+ */
1832
+ async ensureOnDisk(): Promise<void> {
1833
+ if (!this.persist || !this.#sessionFile) return;
1834
+ if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
1835
+ await this.#rewriteFile();
1836
+ }
1837
+
1785
1838
  /** Flush pending writes to disk. Call before switching sessions or on shutdown. */
1786
1839
  async flush(): Promise<void> {
1787
1840
  await this.#queuePersistTask(async () => {
@@ -1911,23 +1964,15 @@ export class SessionManager {
1911
1964
 
1912
1965
  const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
1913
1966
  if (!hasAssistant) {
1914
- // Mark as not flushed so when assistant arrives, all entries get written
1967
+ // Mark as not flushed so when assistant arrives, all entries get written.
1915
1968
  this.#flushed = false;
1916
1969
  return;
1917
1970
  }
1918
1971
 
1919
- if (!this.#flushed) {
1920
- this.#flushed = true;
1921
- void this.#queuePersistTask(async () => {
1922
- const writer = this.#ensurePersistWriter();
1923
- if (!writer) return;
1924
- const entries = await Promise.all(
1925
- this.#fileEntries.map(e => prepareEntryForPersistence(e, this.#blobStore)),
1926
- );
1927
- for (const persistedEntry of entries) {
1928
- await writer.write(persistedEntry);
1929
- }
1930
- });
1972
+ if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
1973
+ // Full flush: rewrite the entire file atomically to avoid
1974
+ // duplicating entries if the file already exists (e.g. from ensureOnDisk).
1975
+ void this.#rewriteFile();
1931
1976
  } else {
1932
1977
  void this.#queuePersistTask(async () => {
1933
1978
  const writer = this.#ensurePersistWriter();
@@ -2299,6 +2344,26 @@ export class SessionManager {
2299
2344
  return buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
2300
2345
  }
2301
2346
 
2347
+ /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
2348
+ sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
2349
+ let didSanitize = false;
2350
+ for (const entry of this.#fileEntries) {
2351
+ if (entry.type !== "message" || entry.message.role !== "assistant") {
2352
+ continue;
2353
+ }
2354
+
2355
+ const sanitizedMessage = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
2356
+ if (sanitizedMessage === entry.message) {
2357
+ continue;
2358
+ }
2359
+
2360
+ entry.message = sanitizedMessage;
2361
+ didSanitize = true;
2362
+ }
2363
+
2364
+ return didSanitize;
2365
+ }
2366
+
2302
2367
  /**
2303
2368
  * Get session header.
2304
2369
  */
@@ -2547,6 +2612,7 @@ export class SessionManager {
2547
2612
  newHeader.title = sourceHeader?.title;
2548
2613
  manager.#fileEntries = [newHeader, ...historyEntries];
2549
2614
  manager.#sessionName = newHeader.title;
2615
+ manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
2550
2616
  manager.#buildIndex();
2551
2617
  await manager.#rewriteFile();
2552
2618
  return manager;
package/src/tools/ask.ts CHANGED
@@ -16,13 +16,12 @@
16
16
  */
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
- import type { Component } from "@oh-my-pi/pi-tui";
20
- import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
19
+ import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
21
20
  import { untilAborted } from "@oh-my-pi/pi-utils";
22
21
  import { type Static, Type } from "@sinclair/typebox";
23
22
  import { renderPromptTemplate } from "../config/prompt-templates";
24
23
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
25
- import { type Theme, theme } from "../modes/theme/theme";
24
+ import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
26
25
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
27
26
  import { renderStatusLine } from "../tui";
28
27
  import type { ToolSession } from ".";
@@ -574,10 +573,13 @@ interface AskRenderArgs {
574
573
  export const askToolRenderer = {
575
574
  renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
576
575
  const label = formatTitle("Ask", uiTheme);
576
+ const mdTheme = getMarkdownTheme();
577
+ const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
577
578
 
578
579
  // Multi-part questions
579
580
  if (args.questions && args.questions.length > 0) {
580
- let text = `${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`;
581
+ const container = new Container();
582
+ container.addChild(new Text(`${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`, 0, 0));
581
583
 
582
584
  for (let i = 0; i < args.questions.length; i++) {
583
585
  const q = args.questions[i];
@@ -585,25 +587,29 @@ export const askToolRenderer = {
585
587
  const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch;
586
588
  const continuation = isLastQ ? " " : uiTheme.tree.vertical;
587
589
 
588
- // Question line with metadata
589
590
  const meta: string[] = [];
590
591
  if (q.multi) meta.push("multi");
591
592
  if (q.options?.length) meta.push(`options:${q.options.length}`);
592
593
  const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
593
594
 
594
- text += `\n ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)} ${uiTheme.fg("accent", q.question)}${metaStr}`;
595
+ container.addChild(
596
+ new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, 0, 0),
597
+ );
598
+ container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle));
595
599
 
596
- // Options under question
597
600
  if (q.options?.length) {
601
+ let optText = "";
598
602
  for (let j = 0; j < q.options.length; j++) {
599
603
  const opt = q.options[j];
600
604
  const isLastOpt = j === q.options.length - 1;
601
605
  const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
602
- text += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
606
+ const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
607
+ optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
603
608
  }
609
+ container.addChild(new Text(optText, 0, 0));
604
610
  }
605
611
  }
606
- return new Text(text, 0, 0);
612
+ return container;
607
613
  }
608
614
 
609
615
  // Single question
@@ -611,22 +617,26 @@ export const askToolRenderer = {
611
617
  return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
612
618
  }
613
619
 
614
- let text = `${label} ${uiTheme.fg("accent", args.question)}`;
620
+ const container = new Container();
615
621
  const meta: string[] = [];
616
622
  if (args.multi) meta.push("multi");
617
623
  if (args.options?.length) meta.push(`options:${args.options.length}`);
618
- text += formatMeta(meta, uiTheme);
624
+ container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0));
625
+ container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle));
619
626
 
620
627
  if (args.options?.length) {
628
+ let optText = "";
621
629
  for (let i = 0; i < args.options.length; i++) {
622
630
  const opt = args.options[i];
623
631
  const isLast = i === args.options.length - 1;
624
632
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
625
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
633
+ const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
634
+ optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
626
635
  }
636
+ container.addChild(new Text(optText, 0, 0));
627
637
  }
628
638
 
629
- return new Text(text, 0, 0);
639
+ return container;
630
640
  },
631
641
 
632
642
  renderResult(
@@ -635,6 +645,9 @@ export const askToolRenderer = {
635
645
  uiTheme: Theme,
636
646
  ): Component {
637
647
  const { details } = result;
648
+ const mdTheme = getMarkdownTheme();
649
+ const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
650
+
638
651
  if (!details) {
639
652
  const txt = result.content[0];
640
653
  const fallback = txt?.type === "text" && txt.text ? txt.text : "";
@@ -655,7 +668,8 @@ export const askToolRenderer = {
655
668
  },
656
669
  uiTheme,
657
670
  );
658
- let text = header;
671
+ const container = new Container();
672
+ container.addChild(new Text(header, 0, 0));
659
673
 
660
674
  for (let i = 0; i < details.results.length; i++) {
661
675
  const r = details.results[i];
@@ -667,22 +681,31 @@ export const askToolRenderer = {
667
681
  ? uiTheme.styledSymbol("status.success", "success")
668
682
  : uiTheme.styledSymbol("status.warning", "warning");
669
683
 
670
- text += `\n ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`;
684
+ container.addChild(
685
+ new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0),
686
+ );
687
+ container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
671
688
 
689
+ let answerText = "";
672
690
  if (r.customInput) {
673
- text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
691
+ answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
674
692
  } else if (r.selectedOptions.length > 0) {
675
693
  for (let j = 0; j < r.selectedOptions.length; j++) {
676
694
  const isLast = j === r.selectedOptions.length - 1;
677
695
  const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
678
- text += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`;
696
+ const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
697
+ uiTheme.fg("toolOutput", t),
698
+ );
699
+ answerText += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
679
700
  }
680
701
  } else {
681
- text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
702
+ answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
703
+ }
704
+ if (answerText) {
705
+ container.addChild(new Text(answerText, 0, 0));
682
706
  }
683
707
  }
684
-
685
- return new Text(text, 0, 0);
708
+ return container;
686
709
  }
687
710
 
688
711
  // Single question result
@@ -693,25 +716,28 @@ export const askToolRenderer = {
693
716
  }
694
717
 
695
718
  const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0);
696
- const header = renderStatusLine(
697
- { icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question },
698
- uiTheme,
699
- );
700
-
701
- let text = header;
719
+ const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme);
720
+ const container = new Container();
721
+ container.addChild(new Text(header, 0, 0));
722
+ container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
702
723
 
724
+ let answerText = "";
703
725
  if (details.customInput) {
704
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
726
+ answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
705
727
  } else if (details.selectedOptions && details.selectedOptions.length > 0) {
706
728
  for (let i = 0; i < details.selectedOptions.length; i++) {
707
729
  const isLast = i === details.selectedOptions.length - 1;
708
730
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
709
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", details.selectedOptions[i])}`;
731
+ const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
732
+ uiTheme.fg("toolOutput", t),
733
+ );
734
+ answerText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
710
735
  }
711
736
  } else {
712
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
737
+ answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
713
738
  }
739
+ container.addChild(new Text(answerText, 0, 0));
714
740
 
715
- return new Text(text, 0, 0);
741
+ return container;
716
742
  },
717
743
  };
@@ -5,45 +5,7 @@
5
5
  * this interceptor provides helpful error messages directing them to use
6
6
  * the specialized tools instead.
7
7
  */
8
- import type { BashInterceptorRule } from "../config/settings-schema";
9
-
10
- export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
11
- {
12
- pattern: "^\\s*(cat|head|tail|less|more)\\s+",
13
- tool: "read",
14
- message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
15
- },
16
- {
17
- pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
18
- tool: "grep",
19
- message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
20
- },
21
- {
22
- pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
23
- tool: "find",
24
- message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
25
- },
26
- {
27
- pattern: "^\\s*sed\\s+(-i|--in-place)",
28
- tool: "edit",
29
- message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
30
- },
31
- {
32
- pattern: "^\\s*perl\\s+.*-[pn]?i",
33
- tool: "edit",
34
- message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
35
- },
36
- {
37
- pattern: "^\\s*awk\\s+.*-i\\s+inplace",
38
- tool: "edit",
39
- message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
40
- },
41
- {
42
- pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
43
- tool: "write",
44
- message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
45
- },
46
- ];
8
+ import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema";
47
9
 
48
10
  export interface InterceptionResult {
49
11
  /** If true, the bash command should be blocked */
@@ -131,7 +131,7 @@ async function resolveInternalUrlToPath(
131
131
  return resolvedLocalPath;
132
132
  }
133
133
 
134
- if (!internalRouter || !internalRouter.canHandle(url)) {
134
+ if (!internalRouter?.canHandle(url)) {
135
135
  throw new ToolError(
136
136
  `Cannot resolve ${scheme}:// URL in bash command: ${url}\n` +
137
137
  "Internal URL router is unavailable for this protocol in the current session.",
@@ -564,7 +564,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
564
564
  if (this.#page && !this.#page.isClosed()) {
565
565
  return this.#page;
566
566
  }
567
- if (!this.#browser || !this.#browser.isConnected()) {
567
+ if (!this.#browser?.isConnected()) {
568
568
  return this.#resetBrowser(params);
569
569
  }
570
570
  this.#page = await this.#browser.newPage();
@@ -287,7 +287,7 @@ async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise
287
287
  throw new Error(`Image download failed (${response.status}): ${rawText}`);
288
288
  }
289
289
  const contentType = response.headers.get("content-type")?.split(";")[0];
290
- if (!contentType || !contentType.startsWith("image/")) {
290
+ if (!contentType?.startsWith("image/")) {
291
291
  throw new Error(`Unsupported image type from URL: ${imageUrl}`);
292
292
  }
293
293
  const buffer = await response.bytes();
@@ -54,7 +54,7 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
54
54
  ): Promise<AgentToolResult<ResolveToolDetails>> {
55
55
  return untilAborted(signal, async () => {
56
56
  const store = this.session.pendingActionStore;
57
- if (!store || !store.hasPending) {
57
+ if (!store?.hasPending) {
58
58
  throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
59
59
  }
60
60
 
@@ -0,0 +1,88 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+
3
+ const EXIT_STDIO_GRACE_MS = 100;
4
+
5
+ /**
6
+ * Wait for a child process to terminate without hanging on inherited stdio handles.
7
+ *
8
+ * Daemonized descendants can inherit the child's stdout/stderr pipe handles. In that
9
+ * case the child emits `exit`, but `close` can hang forever even though the original
10
+ * process is already gone. We wait briefly for stdio to end, then forcibly stop
11
+ * tracking the inherited handles.
12
+ */
13
+ export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
14
+ const { promise, resolve, reject } = Promise.withResolvers<number | null>();
15
+
16
+ let settled = false;
17
+ let exited = false;
18
+ let exitCode: number | null = null;
19
+ let postExitTimer: NodeJS.Timeout | undefined;
20
+ let stdoutEnded = child.stdout === null;
21
+ let stderrEnded = child.stderr === null;
22
+
23
+ const cleanup = () => {
24
+ if (postExitTimer) {
25
+ clearTimeout(postExitTimer);
26
+ postExitTimer = undefined;
27
+ }
28
+ child.removeListener("error", onError);
29
+ child.removeListener("exit", onExit);
30
+ child.removeListener("close", onClose);
31
+ child.stdout?.removeListener("end", onStdoutEnd);
32
+ child.stderr?.removeListener("end", onStderrEnd);
33
+ };
34
+
35
+ const finalize = (code: number | null) => {
36
+ if (settled) return;
37
+ settled = true;
38
+ cleanup();
39
+ child.stdout?.destroy();
40
+ child.stderr?.destroy();
41
+ resolve(code);
42
+ };
43
+
44
+ const maybeFinalizeAfterExit = () => {
45
+ if (!exited || settled) return;
46
+ if (stdoutEnded && stderrEnded) {
47
+ finalize(exitCode);
48
+ }
49
+ };
50
+
51
+ const onStdoutEnd = () => {
52
+ stdoutEnded = true;
53
+ maybeFinalizeAfterExit();
54
+ };
55
+
56
+ const onStderrEnd = () => {
57
+ stderrEnded = true;
58
+ maybeFinalizeAfterExit();
59
+ };
60
+
61
+ const onError = (err: Error) => {
62
+ if (settled) return;
63
+ settled = true;
64
+ cleanup();
65
+ reject(err);
66
+ };
67
+
68
+ const onExit = (code: number | null) => {
69
+ exited = true;
70
+ exitCode = code;
71
+ maybeFinalizeAfterExit();
72
+ if (!settled) {
73
+ postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
74
+ }
75
+ };
76
+
77
+ const onClose = (code: number | null) => {
78
+ finalize(code);
79
+ };
80
+
81
+ child.stdout?.once("end", onStdoutEnd);
82
+ child.stderr?.once("end", onStderrEnd);
83
+ child.once("error", onError);
84
+ child.once("exit", onExit);
85
+ child.once("close", onClose);
86
+
87
+ return promise;
88
+ }
@@ -1,12 +1,14 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
3
  import { formatBytes } from "@oh-my-pi/pi-utils";
3
4
  import { resolveReadPath } from "../tools/path-utils";
5
+ import { convertToPng } from "./image-convert";
4
6
  import { formatDimensionNote, resizeImage } from "./image-resize";
5
7
  import { detectSupportedImageMimeTypeFromFile } from "./mime";
6
8
 
7
9
  export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
8
10
  const MAX_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
9
-
11
+ export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
10
12
  export interface ImageMetadata {
11
13
  mimeType: string;
12
14
  bytes: number;
@@ -25,6 +27,14 @@ export interface LoadedImageInput {
25
27
  bytes: number;
26
28
  }
27
29
 
30
+ export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
31
+ if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
32
+ return image;
33
+ }
34
+ const converted = await convertToPng(image.data, image.mimeType);
35
+ return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
36
+ }
37
+
28
38
  export interface ReadImageMetadataOptions {
29
39
  path: string;
30
40
  cwd: string;