@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.1

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 (129) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/find.ts +7 -1
  62. package/src/core/tools/gemini-image.ts +361 -0
  63. package/src/core/tools/git.ts +216 -0
  64. package/src/core/tools/index.ts +28 -15
  65. package/src/core/tools/ls.ts +9 -2
  66. package/src/core/tools/lsp/config.ts +5 -4
  67. package/src/core/tools/lsp/index.ts +17 -12
  68. package/src/core/tools/lsp/render.ts +39 -47
  69. package/src/core/tools/read.ts +66 -29
  70. package/src/core/tools/render-utils.ts +268 -0
  71. package/src/core/tools/renderers.ts +243 -225
  72. package/src/core/tools/task/discovery.ts +2 -2
  73. package/src/core/tools/task/executor.ts +66 -58
  74. package/src/core/tools/task/index.ts +29 -10
  75. package/src/core/tools/task/model-resolver.ts +8 -13
  76. package/src/core/tools/task/omp-command.ts +24 -0
  77. package/src/core/tools/task/render.ts +37 -62
  78. package/src/core/tools/task/types.ts +3 -0
  79. package/src/core/tools/web-fetch.ts +29 -28
  80. package/src/core/tools/web-search/index.ts +6 -5
  81. package/src/core/tools/web-search/providers/exa.ts +6 -5
  82. package/src/core/tools/web-search/render.ts +66 -111
  83. package/src/core/voice-controller.ts +135 -0
  84. package/src/core/voice-supervisor.ts +1003 -0
  85. package/src/core/voice.ts +308 -0
  86. package/src/discovery/builtin.ts +75 -1
  87. package/src/discovery/claude.ts +47 -1
  88. package/src/discovery/codex.ts +54 -2
  89. package/src/discovery/gemini.ts +55 -2
  90. package/src/discovery/helpers.ts +100 -1
  91. package/src/discovery/index.ts +2 -0
  92. package/src/index.ts +14 -9
  93. package/src/lib/worktree/collapse.ts +179 -0
  94. package/src/lib/worktree/constants.ts +14 -0
  95. package/src/lib/worktree/errors.ts +23 -0
  96. package/src/lib/worktree/git.ts +110 -0
  97. package/src/lib/worktree/index.ts +23 -0
  98. package/src/lib/worktree/operations.ts +216 -0
  99. package/src/lib/worktree/session.ts +114 -0
  100. package/src/lib/worktree/stats.ts +67 -0
  101. package/src/main.ts +61 -37
  102. package/src/migrations.ts +37 -7
  103. package/src/modes/interactive/components/bash-execution.ts +6 -4
  104. package/src/modes/interactive/components/custom-editor.ts +55 -0
  105. package/src/modes/interactive/components/custom-message.ts +95 -0
  106. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  107. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  108. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  109. package/src/modes/interactive/components/extensions/types.ts +1 -0
  110. package/src/modes/interactive/components/footer.ts +324 -0
  111. package/src/modes/interactive/components/hook-selector.ts +3 -3
  112. package/src/modes/interactive/components/model-selector.ts +7 -6
  113. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  114. package/src/modes/interactive/components/settings-defs.ts +55 -6
  115. package/src/modes/interactive/components/status-line.ts +45 -37
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +643 -113
  118. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  119. package/src/modes/print-mode.ts +14 -72
  120. package/src/modes/rpc/rpc-client.ts +23 -9
  121. package/src/modes/rpc/rpc-mode.ts +137 -125
  122. package/src/modes/rpc/rpc-types.ts +46 -24
  123. package/src/prompts/task.md +1 -0
  124. package/src/prompts/tools/gemini-image.md +4 -0
  125. package/src/prompts/tools/git.md +9 -0
  126. package/src/prompts/voice-summary.md +12 -0
  127. package/src/utils/image-convert.ts +26 -0
  128. package/src/utils/image-resize.ts +215 -0
  129. package/src/utils/shell-snapshot.ts +22 -20
@@ -1,19 +1,16 @@
1
1
  import {
2
- appendFileSync,
3
2
  closeSync,
4
3
  createWriteStream,
5
4
  existsSync,
6
5
  fsyncSync,
7
6
  mkdirSync,
8
7
  openSync,
9
- readdirSync,
10
8
  readFileSync,
11
9
  readSync,
12
10
  renameSync,
13
11
  statSync,
14
12
  unlinkSync,
15
13
  type WriteStream,
16
- writeFileSync,
17
14
  } from "node:fs";
18
15
  import { basename, join, resolve } from "node:path";
19
16
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
@@ -23,13 +20,14 @@ import sharp from "sharp";
23
20
  import { getAgentDir as getDefaultAgentDir } from "../config";
24
21
  import {
25
22
  type BashExecutionMessage,
23
+ type CustomMessage,
26
24
  createBranchSummaryMessage,
27
25
  createCompactionSummaryMessage,
28
- createHookMessage,
26
+ createCustomMessage,
29
27
  type HookMessage,
30
28
  } from "./messages";
31
29
 
32
- export const CURRENT_SESSION_VERSION = 2;
30
+ export const CURRENT_SESSION_VERSION = 3;
33
31
 
34
32
  export interface SessionHeader {
35
33
  type: "session";
@@ -75,27 +73,27 @@ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
75
73
  summary: string;
76
74
  firstKeptEntryId: string;
77
75
  tokensBefore: number;
78
- /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
76
+ /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
79
77
  details?: T;
80
- /** True if generated by a hook, undefined/false if pi-generated (backward compatible) */
81
- fromHook?: boolean;
78
+ /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
79
+ fromExtension?: boolean;
82
80
  }
83
81
 
84
82
  export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
85
83
  type: "branch_summary";
86
84
  fromId: string;
87
85
  summary: string;
88
- /** Hook-specific data (not sent to LLM) */
86
+ /** Extension-specific data (not sent to LLM) */
89
87
  details?: T;
90
- /** True if generated by a hook, false if pi-generated */
91
- fromHook?: boolean;
88
+ /** True if generated by an extension, false if pi-generated */
89
+ fromExtension?: boolean;
92
90
  }
93
91
 
94
92
  /**
95
- * Custom entry for hooks to store hook-specific data in the session.
96
- * Use customType to identify your hook's entries.
93
+ * Custom entry for extensions to store extension-specific data in the session.
94
+ * Use customType to identify your extension's entries.
97
95
  *
98
- * Purpose: Persist hook state across session reloads. On reload, hooks can
96
+ * Purpose: Persist extension state across session reloads. On reload, extensions can
99
97
  * scan entries for their customType and reconstruct internal state.
100
98
  *
101
99
  * Does NOT participate in LLM context (ignored by buildSessionContext).
@@ -122,12 +120,12 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
122
120
  }
123
121
 
124
122
  /**
125
- * Custom message entry for hooks to inject messages into LLM context.
126
- * Use customType to identify your hook's entries.
123
+ * Custom message entry for extensions to inject messages into LLM context.
124
+ * Use customType to identify your extension's entries.
127
125
  *
128
126
  * Unlike CustomEntry, this DOES participate in LLM context.
129
127
  * The content is converted to a user message in buildSessionContext().
130
- * Use details for hook-specific metadata (not sent to LLM).
128
+ * Use details for extension-specific metadata (not sent to LLM).
131
129
  *
132
130
  * display controls TUI rendering:
133
131
  * - false: hidden entirely
@@ -239,8 +237,22 @@ function migrateV1ToV2(entries: FileEntry[]): void {
239
237
  }
240
238
  }
241
239
 
242
- // Add future migrations here:
243
- // function migrateV2ToV3(entries: FileEntry[]): void { ... }
240
+ /** Migrate v2 v3: rename hookMessage role to custom. Mutates in place. */
241
+ function migrateV2ToV3(entries: FileEntry[]): void {
242
+ for (const entry of entries) {
243
+ if (entry.type === "session") {
244
+ entry.version = 3;
245
+ continue;
246
+ }
247
+
248
+ if (entry.type === "message") {
249
+ const msg = entry.message as { role?: string };
250
+ if (msg.role === "hookMessage") {
251
+ (entry.message as { role: string }).role = "custom";
252
+ }
253
+ }
254
+ }
255
+ }
244
256
 
245
257
  /**
246
258
  * Run all necessary migrations to bring entries to current version.
@@ -253,7 +265,7 @@ function migrateToCurrentVersion(entries: FileEntry[]): boolean {
253
265
  if (version >= CURRENT_SESSION_VERSION) return false;
254
266
 
255
267
  if (version < 2) migrateV1ToV2(entries);
256
- // if (version < 3) migrateV2ToV3(entries);
268
+ if (version < 3) migrateV2ToV3(entries);
257
269
 
258
270
  return true;
259
271
  }
@@ -380,7 +392,7 @@ export function buildSessionContext(
380
392
  messages.push(entry.message);
381
393
  } else if (entry.type === "custom_message") {
382
394
  messages.push(
383
- createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
395
+ createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
384
396
  );
385
397
  } else if (entry.type === "branch_summary" && entry.summary) {
386
398
  messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
@@ -438,7 +450,7 @@ function getDefaultSessionDir(cwd: string): string {
438
450
  export function loadEntriesFromFile(filePath: string): FileEntry[] {
439
451
  if (!existsSync(filePath)) return [];
440
452
 
441
- const content = readFileSync(filePath, "utf8");
453
+ const content = readFileSync(filePath, "utf-8");
442
454
  const entries: FileEntry[] = [];
443
455
  const lines = content.trim().split("\n");
444
456
 
@@ -535,10 +547,9 @@ function getSortedSessions(sessionDir: string): RecentSessionInfo[] {
535
547
  return header;
536
548
  };
537
549
 
538
- return readdirSync(sessionDir)
550
+ return Array.from(new Bun.Glob("*.jsonl").scanSync(sessionDir))
539
551
  .map((f) => {
540
552
  try {
541
- if (!f.endsWith(".jsonl")) return null;
542
553
  const path = join(sessionDir, f);
543
554
  const fd = openSync(path, "r");
544
555
  try {
@@ -927,8 +938,8 @@ export class SessionManager {
927
938
  }
928
939
 
929
940
  /** Initialize with a specific session file (used by factory methods) */
930
- private async _initSessionFile(sessionFile: string): Promise<void> {
931
- await this.setSessionFile(sessionFile);
941
+ private _initSessionFile(sessionFile: string): void {
942
+ this.setSessionFile(sessionFile);
932
943
  }
933
944
 
934
945
  /** Initialize with a new session (used by factory methods) */
@@ -937,23 +948,25 @@ export class SessionManager {
937
948
  }
938
949
 
939
950
  /** Switch to a different session file (used for resume and branching) */
940
- async setSessionFile(sessionFile: string): Promise<void> {
941
- await this._closePersistWriter();
951
+ setSessionFile(sessionFile: string): void {
952
+ void this._closePersistWriter();
942
953
  this.persistError = undefined;
943
954
  this.persistErrorReported = false;
944
955
  this.sessionFile = resolve(sessionFile);
945
956
  if (existsSync(this.sessionFile)) {
946
- this.fileEntries = loadEntriesFromFile(this.sessionFile);
947
- const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
948
- this.sessionId = header?.id ?? nanoid();
949
- this.sessionTitle = header?.title;
950
-
951
- if (migrateToCurrentVersion(this.fileEntries)) {
952
- await this._rewriteFile();
953
- }
957
+ void (async () => {
958
+ this.fileEntries = await loadEntriesFromFile(this.sessionFile!);
959
+ const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
960
+ this.sessionId = header?.id ?? nanoid();
961
+ this.sessionTitle = header?.title;
962
+
963
+ if (migrateToCurrentVersion(this.fileEntries)) {
964
+ await this._rewriteFile();
965
+ }
954
966
 
955
- this._buildIndex();
956
- this.flushed = true;
967
+ this._buildIndex();
968
+ this.flushed = true;
969
+ })();
957
970
  } else {
958
971
  this._newSessionSync();
959
972
  }
@@ -1178,7 +1191,7 @@ export class SessionManager {
1178
1191
  fileHeader.title = title;
1179
1192
  lines[0] = JSON.stringify(fileHeader);
1180
1193
  const tempPath = join(resolve(sessionFile, ".."), `.${basename(sessionFile)}.${nanoid(6)}.tmp`);
1181
- writeFileSync(tempPath, lines.join("\n"));
1194
+ await Bun.write(tempPath, lines.join("\n"));
1182
1195
  const fd = openSync(tempPath, "r");
1183
1196
  try {
1184
1197
  fsyncSync(fd);
@@ -1265,7 +1278,7 @@ export class SessionManager {
1265
1278
  * so it is easier to find them.
1266
1279
  * These need to be appended via appendCompaction() and appendBranchSummary() methods.
1267
1280
  */
1268
- appendMessage(message: Message | HookMessage | BashExecutionMessage): string {
1281
+ appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage): string {
1269
1282
  const entry: SessionMessageEntry = {
1270
1283
  type: "message",
1271
1284
  id: generateId(this.byId),
@@ -1314,7 +1327,7 @@ export class SessionManager {
1314
1327
  firstKeptEntryId: string,
1315
1328
  tokensBefore: number,
1316
1329
  details?: T,
1317
- fromHook?: boolean,
1330
+ fromExtension?: boolean,
1318
1331
  ): string {
1319
1332
  const entry: CompactionEntry<T> = {
1320
1333
  type: "compaction",
@@ -1325,13 +1338,13 @@ export class SessionManager {
1325
1338
  firstKeptEntryId,
1326
1339
  tokensBefore,
1327
1340
  details,
1328
- fromHook,
1341
+ fromExtension,
1329
1342
  };
1330
1343
  this._appendEntry(entry);
1331
1344
  return entry.id;
1332
1345
  }
1333
1346
 
1334
- /** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
1347
+ /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
1335
1348
  appendCustomEntry(customType: string, data?: unknown): string {
1336
1349
  const entry: CustomEntry = {
1337
1350
  type: "custom",
@@ -1346,11 +1359,11 @@ export class SessionManager {
1346
1359
  }
1347
1360
 
1348
1361
  /**
1349
- * Append a custom message entry (for hooks) that participates in LLM context.
1362
+ * Append a custom message entry (for extensions) that participates in LLM context.
1350
1363
  * @param customType Hook identifier for filtering on reload
1351
1364
  * @param content Message content (string or TextContent/ImageContent array)
1352
1365
  * @param display Whether to show in TUI (true = styled display, false = hidden)
1353
- * @param details Optional hook-specific metadata (not sent to LLM)
1366
+ * @param details Optional extension-specific metadata (not sent to LLM)
1354
1367
  * @returns Entry id
1355
1368
  */
1356
1369
  appendCustomMessageEntry<T = unknown>(
@@ -1589,7 +1602,7 @@ export class SessionManager {
1589
1602
  * Same as branch(), but also appends a branch_summary entry that captures
1590
1603
  * context from the abandoned conversation path.
1591
1604
  */
1592
- branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {
1605
+ branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
1593
1606
  if (branchFromId !== null && !this.byId.has(branchFromId)) {
1594
1607
  throw new Error(`Entry ${branchFromId} not found`);
1595
1608
  }
@@ -1602,7 +1615,7 @@ export class SessionManager {
1602
1615
  fromId: branchFromId ?? "root",
1603
1616
  summary,
1604
1617
  details,
1605
- fromHook,
1618
+ fromExtension,
1606
1619
  };
1607
1620
  this._appendEntry(entry);
1608
1621
  return entry.id;
@@ -1646,9 +1659,11 @@ export class SessionManager {
1646
1659
  }
1647
1660
 
1648
1661
  if (this.persist) {
1649
- appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`);
1662
+ const file = Bun.file(newSessionFile);
1663
+ const writer = file.writer();
1664
+ writer.write(`${JSON.stringify(header)}\n`);
1650
1665
  for (const entry of pathWithoutLabels) {
1651
- appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
1666
+ writer.write(`${JSON.stringify(entry)}\n`);
1652
1667
  }
1653
1668
  // Write fresh label entries at the end
1654
1669
  const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
@@ -1663,11 +1678,12 @@ export class SessionManager {
1663
1678
  targetId,
1664
1679
  label,
1665
1680
  };
1666
- appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
1681
+ writer.write(`${JSON.stringify(labelEntry)}\n`);
1667
1682
  pathEntryIds.add(labelEntry.id);
1668
1683
  labelEntries.push(labelEntry);
1669
1684
  parentId = labelEntry.id;
1670
1685
  }
1686
+ writer.end();
1671
1687
  this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1672
1688
  this.sessionId = newSessionId;
1673
1689
  this._buildIndex();
@@ -1714,13 +1730,13 @@ export class SessionManager {
1714
1730
  */
1715
1731
  static async open(path: string, sessionDir?: string): Promise<SessionManager> {
1716
1732
  // Extract cwd from session header if possible, otherwise use process.cwd()
1717
- const entries = loadEntriesFromFile(path);
1733
+ const entries = await loadEntriesFromFile(path);
1718
1734
  const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
1719
1735
  const cwd = header?.cwd ?? process.cwd();
1720
1736
  // If no sessionDir provided, derive from file's parent directory
1721
1737
  const dir = sessionDir ?? resolve(path, "..");
1722
1738
  const manager = new SessionManager(cwd, dir, true);
1723
- await manager._initSessionFile(path);
1739
+ manager._initSessionFile(path);
1724
1740
  return manager;
1725
1741
  }
1726
1742
 
@@ -1729,12 +1745,12 @@ export class SessionManager {
1729
1745
  * @param cwd Working directory
1730
1746
  * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1731
1747
  */
1732
- static async continueRecent(cwd: string, sessionDir?: string): Promise<SessionManager> {
1748
+ static continueRecent(cwd: string, sessionDir?: string): SessionManager {
1733
1749
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
1734
1750
  const mostRecent = findMostRecentSession(dir);
1735
1751
  const manager = new SessionManager(cwd, dir, true);
1736
1752
  if (mostRecent) {
1737
- await manager._initSessionFile(mostRecent);
1753
+ manager._initSessionFile(mostRecent);
1738
1754
  } else {
1739
1755
  manager._initNewSession();
1740
1756
  }
@@ -1758,13 +1774,11 @@ export class SessionManager {
1758
1774
  const sessions: SessionInfo[] = [];
1759
1775
 
1760
1776
  try {
1761
- const files = readdirSync(dir)
1762
- .filter((f) => f.endsWith(".jsonl"))
1763
- .map((f) => join(dir, f));
1777
+ const files = Array.from(new Bun.Glob("*.jsonl").scanSync(dir)).map((f) => join(dir, f));
1764
1778
 
1765
1779
  for (const file of files) {
1766
1780
  try {
1767
- const content = readFileSync(file, "utf8");
1781
+ const content = readFileSync(file, "utf-8");
1768
1782
  const lines = content.trim().split("\n");
1769
1783
  if (lines.length === 0) continue;
1770
1784
 
@@ -42,6 +42,16 @@ export interface TerminalSettings {
42
42
  showImages?: boolean; // default: true (only relevant if terminal supports images)
43
43
  }
44
44
 
45
+ export interface ImageSettings {
46
+ autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
47
+ }
48
+
49
+ export type NotificationMethod = "bell" | "osc99" | "osc9" | "auto" | "off";
50
+
51
+ export interface NotificationSettings {
52
+ onComplete?: NotificationMethod; // default: "auto"
53
+ }
54
+
45
55
  export interface ExaSettings {
46
56
  enabled?: boolean; // default: true (master toggle for all Exa tools)
47
57
  enableSearch?: boolean; // default: true (search, deep, code, crawl)
@@ -81,6 +91,15 @@ export interface TtsrSettings {
81
91
  repeatGap?: number; // default: 10
82
92
  }
83
93
 
94
+ export interface VoiceSettings {
95
+ enabled?: boolean; // default: false
96
+ transcriptionModel?: string; // default: "whisper-1"
97
+ transcriptionLanguage?: string; // optional language hint (e.g., "en")
98
+ ttsModel?: string; // default: "gpt-4o-mini-tts"
99
+ ttsVoice?: string; // default: "alloy"
100
+ ttsFormat?: "wav" | "mp3" | "opus" | "aac" | "flac"; // default: "wav"
101
+ }
102
+
84
103
  export type StatusLineSegmentId =
85
104
  | "pi"
86
105
  | "model"
@@ -125,7 +144,9 @@ export interface Settings {
125
144
  /** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
126
145
  modelRoles?: Record<string, string>;
127
146
  defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
128
- queueMode?: "all" | "one-at-a-time";
147
+ steeringMode?: "all" | "one-at-a-time";
148
+ followUpMode?: "all" | "one-at-a-time";
149
+ queueMode?: "all" | "one-at-a-time"; // legacy
129
150
  interruptMode?: "immediate" | "wait";
130
151
  theme?: string;
131
152
  symbolPreset?: SymbolPreset; // default: uses theme's preset or "unicode"
@@ -135,11 +156,13 @@ export interface Settings {
135
156
  hideThinkingBlock?: boolean;
136
157
  shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
137
158
  collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
138
- hooks?: string[]; // Array of hook file paths
139
- customTools?: string[]; // Array of custom tool file paths
159
+ doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
160
+ extensions?: string[]; // Array of extension file paths
140
161
  skills?: SkillsSettings;
141
162
  commands?: CommandsSettings;
142
163
  terminal?: TerminalSettings;
164
+ images?: ImageSettings;
165
+ notifications?: NotificationSettings;
143
166
  enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
144
167
  exa?: ExaSettings;
145
168
  bashInterceptor?: BashInterceptorSettings;
@@ -147,6 +170,7 @@ export interface Settings {
147
170
  lsp?: LspSettings;
148
171
  edit?: EditSettings;
149
172
  ttsr?: TtsrSettings;
173
+ voice?: VoiceSettings;
150
174
  disabledProviders?: string[]; // Discovery provider IDs that are disabled
151
175
  disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
152
176
  statusLine?: StatusLineSettings; // Status line configuration
@@ -232,13 +256,24 @@ export class SettingsManager {
232
256
  }
233
257
  try {
234
258
  const content = readFileSync(path, "utf-8");
235
- return JSON.parse(content);
259
+ const settings = JSON.parse(content);
260
+ return SettingsManager.migrateSettings(settings as Record<string, unknown>);
236
261
  } catch (error) {
237
262
  console.error(`Warning: Could not read settings file ${path}: ${error}`);
238
263
  return {};
239
264
  }
240
265
  }
241
266
 
267
+ /** Migrate old settings format to new format */
268
+ private static migrateSettings(settings: Record<string, unknown>): Settings {
269
+ // Migrate queueMode -> steeringMode
270
+ if ("queueMode" in settings && !("steeringMode" in settings)) {
271
+ settings.steeringMode = settings.queueMode;
272
+ delete settings.queueMode;
273
+ }
274
+ return settings as Settings;
275
+ }
276
+
242
277
  private loadProjectSettings(): Settings {
243
278
  if (!this.cwd) return {};
244
279
 
@@ -253,7 +288,7 @@ export class SettingsManager {
253
288
  }
254
289
  }
255
290
 
256
- return merged;
291
+ return SettingsManager.migrateSettings(merged as Record<string, unknown>);
257
292
  }
258
293
 
259
294
  /** Apply additional overrides on top of current settings */
@@ -315,12 +350,21 @@ export class SettingsManager {
315
350
  return { ...this.settings.modelRoles };
316
351
  }
317
352
 
318
- getQueueMode(): "all" | "one-at-a-time" {
319
- return this.settings.queueMode || "one-at-a-time";
353
+ getSteeringMode(): "all" | "one-at-a-time" {
354
+ return this.settings.steeringMode || "one-at-a-time";
320
355
  }
321
356
 
322
- setQueueMode(mode: "all" | "one-at-a-time"): void {
323
- this.globalSettings.queueMode = mode;
357
+ setSteeringMode(mode: "all" | "one-at-a-time"): void {
358
+ this.globalSettings.steeringMode = mode;
359
+ this.save();
360
+ }
361
+
362
+ getFollowUpMode(): "all" | "one-at-a-time" {
363
+ return this.settings.followUpMode || "one-at-a-time";
364
+ }
365
+
366
+ setFollowUpMode(mode: "all" | "one-at-a-time"): void {
367
+ this.globalSettings.followUpMode = mode;
324
368
  this.save();
325
369
  }
326
370
 
@@ -441,21 +485,12 @@ export class SettingsManager {
441
485
  this.save();
442
486
  }
443
487
 
444
- getHookPaths(): string[] {
445
- return [...(this.settings.hooks ?? [])];
446
- }
447
-
448
- setHookPaths(paths: string[]): void {
449
- this.globalSettings.hooks = paths;
450
- this.save();
451
- }
452
-
453
- getCustomToolPaths(): string[] {
454
- return [...(this.settings.customTools ?? [])];
488
+ getExtensionPaths(): string[] {
489
+ return [...(this.settings.extensions ?? [])];
455
490
  }
456
491
 
457
- setCustomToolPaths(paths: string[]): void {
458
- this.globalSettings.customTools = paths;
492
+ setExtensionPaths(paths: string[]): void {
493
+ this.globalSettings.extensions = paths;
459
494
  this.save();
460
495
  }
461
496
 
@@ -504,6 +539,30 @@ export class SettingsManager {
504
539
  this.save();
505
540
  }
506
541
 
542
+ getNotificationOnComplete(): NotificationMethod {
543
+ return this.settings.notifications?.onComplete ?? "auto";
544
+ }
545
+
546
+ setNotificationOnComplete(method: NotificationMethod): void {
547
+ if (!this.globalSettings.notifications) {
548
+ this.globalSettings.notifications = {};
549
+ }
550
+ this.globalSettings.notifications.onComplete = method;
551
+ this.save();
552
+ }
553
+
554
+ getImageAutoResize(): boolean {
555
+ return this.settings.images?.autoResize ?? true;
556
+ }
557
+
558
+ setImageAutoResize(enabled: boolean): void {
559
+ if (!this.globalSettings.images) {
560
+ this.globalSettings.images = {};
561
+ }
562
+ this.globalSettings.images.autoResize = enabled;
563
+ this.save();
564
+ }
565
+
507
566
  getEnabledModels(): string[] | undefined {
508
567
  return this.settings.enabledModels;
509
568
  }
@@ -737,6 +796,34 @@ export class SettingsManager {
737
796
  this.save();
738
797
  }
739
798
 
799
+ getVoiceSettings(): Required<VoiceSettings> {
800
+ return {
801
+ enabled: this.settings.voice?.enabled ?? false,
802
+ transcriptionModel: this.settings.voice?.transcriptionModel ?? "whisper-1",
803
+ transcriptionLanguage: this.settings.voice?.transcriptionLanguage ?? "",
804
+ ttsModel: this.settings.voice?.ttsModel ?? "tts-1",
805
+ ttsVoice: this.settings.voice?.ttsVoice ?? "alloy",
806
+ ttsFormat: this.settings.voice?.ttsFormat ?? "wav",
807
+ };
808
+ }
809
+
810
+ setVoiceSettings(settings: VoiceSettings): void {
811
+ this.globalSettings.voice = { ...this.globalSettings.voice, ...settings };
812
+ this.save();
813
+ }
814
+
815
+ getVoiceEnabled(): boolean {
816
+ return this.settings.voice?.enabled ?? false;
817
+ }
818
+
819
+ setVoiceEnabled(enabled: boolean): void {
820
+ if (!this.globalSettings.voice) {
821
+ this.globalSettings.voice = {};
822
+ }
823
+ this.globalSettings.voice.enabled = enabled;
824
+ this.save();
825
+ }
826
+
740
827
  // ═══════════════════════════════════════════════════════════════════════════
741
828
  // Status Line Settings
742
829
  // ═══════════════════════════════════════════════════════════════════════════
@@ -857,4 +944,13 @@ export class SettingsManager {
857
944
  this.globalSettings.statusLine.showHookStatus = show;
858
945
  this.save();
859
946
  }
947
+
948
+ getDoubleEscapeAction(): "branch" | "tree" {
949
+ return this.settings.doubleEscapeAction ?? "tree";
950
+ }
951
+
952
+ setDoubleEscapeAction(action: "branch" | "tree"): void {
953
+ this.globalSettings.doubleEscapeAction = action;
954
+ this.save();
955
+ }
860
956
  }
@@ -77,9 +77,12 @@ const toolDescriptions: Record<ToolName, string> = {
77
77
  ls: "List directory contents",
78
78
  lsp: "PREFERRED for semantic code queries: go-to-definition, find-all-references, hover (type info), call hierarchy. Returns precise, deterministic results. Use BEFORE grep for symbol lookups.",
79
79
  notebook: "Edit Jupyter notebook cells",
80
+ output: "Output structured data to the user (bypasses tool result formatting)",
80
81
  task: "Spawn a sub-agent to handle complex tasks",
81
82
  web_fetch: "Fetch and render URLs into clean text for LLM consumption",
82
83
  web_search: "Search the web for information",
84
+ report_finding: "Report a finding during code review",
85
+ submit_review: "Submit the final code review with all findings",
83
86
  };
84
87
 
85
88
  /**
@@ -231,6 +234,8 @@ export interface BuildSystemPromptOptions {
231
234
  customPrompt?: string;
232
235
  /** Tools to include in prompt. Default: [read, bash, edit, write] */
233
236
  selectedTools?: ToolName[];
237
+ /** Extra tool descriptions to include in prompt (non built-in tools). */
238
+ extraToolDescriptions?: Array<{ name: string; description: string }>;
234
239
  /** Text to append to system prompt. */
235
240
  appendSystemPrompt?: string;
236
241
  /** Skills settings for discovery. */
@@ -250,6 +255,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
250
255
  const {
251
256
  customPrompt,
252
257
  selectedTools,
258
+ extraToolDescriptions = [],
253
259
  appendSystemPrompt,
254
260
  skillsSettings,
255
261
  cwd,
@@ -304,6 +310,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
304
310
  }
305
311
  }
306
312
 
313
+ // Append custom tool descriptions if provided
314
+ if (extraToolDescriptions.length > 0) {
315
+ prompt += "\n\n# Additional Tools\n\n";
316
+ prompt += extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
317
+ }
318
+
307
319
  // Append git context if in a git repo
308
320
  const gitContext = loadGitContext(resolvedCwd);
309
321
  if (gitContext) {
@@ -335,7 +347,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
335
347
 
336
348
  // Build tools list based on selected tools
337
349
  const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
338
- const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");
350
+ const builtInToolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");
351
+ const extraToolsList =
352
+ extraToolDescriptions.length > 0
353
+ ? extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n")
354
+ : "";
355
+ const toolsList = [builtInToolsList, extraToolsList].filter(Boolean).join("\n");
339
356
 
340
357
  // Generate anti-bash rules (returns null if not applicable)
341
358
  const antiBashSection = generateAntiBashRules(tools);
@@ -413,6 +430,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
413
430
  }
414
431
  }
415
432
 
433
+ // Append custom tool descriptions if provided
434
+ if (extraToolDescriptions.length > 0) {
435
+ prompt += "\n\n# Additional Tools\n\n";
436
+ prompt += extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
437
+ }
438
+
416
439
  // Append git context if in a git repo
417
440
  const gitContext = loadGitContext(resolvedCwd);
418
441
  if (gitContext) {
@@ -0,0 +1,37 @@
1
+ export type NotificationProtocol = "bell" | "osc99" | "osc9";
2
+
3
+ export function detectNotificationProtocol(): NotificationProtocol {
4
+ const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
5
+ const term = process.env.TERM?.toLowerCase() || "";
6
+
7
+ if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
8
+ return "osc99";
9
+ }
10
+
11
+ if (process.env.GHOSTTY_RESOURCES_DIR || termProgram === "ghostty" || term.includes("ghostty")) {
12
+ return "osc9";
13
+ }
14
+
15
+ if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
16
+ return "osc9";
17
+ }
18
+
19
+ if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
20
+ return "osc9";
21
+ }
22
+
23
+ return "bell";
24
+ }
25
+
26
+ export function sendNotification(protocol: NotificationProtocol, message: string): void {
27
+ const payload =
28
+ protocol === "osc99" ? `\x1b]99;;${message}\x1b\\` : protocol === "osc9" ? `\x1b]9;${message}\x1b\\` : "\x07";
29
+
30
+ process.stdout.write(payload);
31
+ }
32
+
33
+ export function isNotificationSuppressed(): boolean {
34
+ const value = process.env.OMP_NOTIFICATIONS?.trim().toLowerCase();
35
+ if (!value) return false;
36
+ return value === "off" || value === "0" || value === "false";
37
+ }