@oh-my-pi/pi-coding-agent 17.0.9 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4659 -4261
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/model-registry.d.ts +3 -1
  10. package/dist/types/config/models-config-schema.d.ts +18 -6
  11. package/dist/types/config/models-config.d.ts +14 -5
  12. package/dist/types/config/provider-globals.d.ts +2 -2
  13. package/dist/types/config/service-tier.d.ts +5 -1
  14. package/dist/types/config/settings-schema.d.ts +316 -47
  15. package/dist/types/config/settings.d.ts +7 -0
  16. package/dist/types/dap/client.d.ts +20 -0
  17. package/dist/types/exec/bash-executor.d.ts +38 -0
  18. package/dist/types/exec/direnv.d.ts +33 -0
  19. package/dist/types/export/html/args.d.ts +15 -0
  20. package/dist/types/export/html/index.d.ts +11 -26
  21. package/dist/types/export/html/web-palette.d.ts +55 -116
  22. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
  25. package/dist/types/live/controller.d.ts +40 -0
  26. package/dist/types/live/protocol.d.ts +85 -0
  27. package/dist/types/live/protocol.test.d.ts +1 -0
  28. package/dist/types/live/transport.d.ts +48 -0
  29. package/dist/types/live/visualizer.d.ts +33 -0
  30. package/dist/types/lsp/client.d.ts +16 -1
  31. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  32. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  33. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  34. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  35. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  36. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  37. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  38. package/dist/types/modes/components/settings-defs.d.ts +7 -1
  39. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  40. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  41. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  43. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  44. package/dist/types/modes/interactive-mode.d.ts +5 -0
  45. package/dist/types/modes/theme/theme.d.ts +1 -1
  46. package/dist/types/modes/types.d.ts +3 -1
  47. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  48. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  49. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  50. package/dist/types/registry/agent-registry.d.ts +11 -4
  51. package/dist/types/sdk.d.ts +15 -1
  52. package/dist/types/secrets/index.d.ts +15 -2
  53. package/dist/types/secrets/obfuscator.d.ts +113 -15
  54. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  55. package/dist/types/session/agent-session-events.d.ts +84 -0
  56. package/dist/types/session/agent-session-types.d.ts +314 -0
  57. package/dist/types/session/agent-session.d.ts +118 -755
  58. package/dist/types/session/async-job-delivery.d.ts +28 -0
  59. package/dist/types/session/bash-runner.d.ts +75 -0
  60. package/dist/types/session/blob-store.d.ts +11 -1
  61. package/dist/types/session/blob-store.test.d.ts +1 -0
  62. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  63. package/dist/types/session/eval-runner.d.ts +49 -0
  64. package/dist/types/session/irc-bridge.d.ts +46 -0
  65. package/dist/types/session/messages.d.ts +22 -0
  66. package/dist/types/session/model-controls.d.ts +180 -0
  67. package/dist/types/session/prewalk.d.ts +46 -0
  68. package/dist/types/session/queued-messages.d.ts +22 -0
  69. package/dist/types/session/redis-session-storage.d.ts +1 -0
  70. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  71. package/dist/types/session/role-models.d.ts +15 -0
  72. package/dist/types/session/session-advisors.d.ts +228 -0
  73. package/dist/types/session/session-entries.d.ts +8 -0
  74. package/dist/types/session/session-handoff.d.ts +70 -0
  75. package/dist/types/session/session-history-format.d.ts +6 -1
  76. package/dist/types/session/session-maintenance.d.ts +246 -0
  77. package/dist/types/session/session-manager.d.ts +41 -0
  78. package/dist/types/session/session-memory.d.ts +56 -0
  79. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  80. package/dist/types/session/session-stats.d.ts +48 -0
  81. package/dist/types/session/session-tools.d.ts +143 -0
  82. package/dist/types/session/session-workspace.d.ts +32 -0
  83. package/dist/types/session/stream-guards.d.ts +44 -0
  84. package/dist/types/session/todo-tracker.d.ts +56 -0
  85. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  86. package/dist/types/session/turn-recovery.d.ts +225 -0
  87. package/dist/types/system-prompt.d.ts +2 -0
  88. package/dist/types/task/executor.d.ts +4 -1
  89. package/dist/types/task/output-manager.d.ts +3 -1
  90. package/dist/types/tools/approval.d.ts +1 -0
  91. package/dist/types/tools/browser/launch.d.ts +5 -0
  92. package/dist/types/tools/image-gen.d.ts +4 -4
  93. package/dist/types/tools/image-providers.d.ts +38 -0
  94. package/dist/types/tools/index.d.ts +5 -0
  95. package/dist/types/tools/path-utils.d.ts +8 -0
  96. package/dist/types/tools/todo.d.ts +10 -4
  97. package/dist/types/tools/xdev.d.ts +5 -1
  98. package/dist/types/tts/streaming-player.d.ts +14 -0
  99. package/dist/types/tts/vocalizer.d.ts +5 -0
  100. package/dist/types/utils/jj.d.ts +29 -0
  101. package/dist/types/utils/lang-from-path.d.ts +1 -0
  102. package/dist/types/utils/title-generator.d.ts +30 -0
  103. package/dist/types/vibe/runtime.d.ts +36 -8
  104. package/dist/types/web/search/index.d.ts +1 -1
  105. package/dist/types/web/search/provider.d.ts +15 -5
  106. package/dist/types/web/search/types.d.ts +94 -0
  107. package/package.json +12 -12
  108. package/scripts/generate-share-viewer.ts +4 -6
  109. package/src/advisor/__tests__/advisor.test.ts +643 -0
  110. package/src/advisor/runtime.ts +144 -43
  111. package/src/async/job-manager.ts +90 -2
  112. package/src/cli/args.ts +2 -0
  113. package/src/cli/auth-gateway-cli.ts +18 -3
  114. package/src/cli/bench-cli.ts +395 -29
  115. package/src/cli/flag-tables.ts +3 -0
  116. package/src/cli/gc-cli.ts +2 -2
  117. package/src/cli/usage-cli.ts +10 -1
  118. package/src/collab/host.ts +3 -2
  119. package/src/commands/bench.ts +18 -4
  120. package/src/commands/launch.ts +4 -0
  121. package/src/config/model-registry.ts +10 -2
  122. package/src/config/models-config-schema.ts +15 -4
  123. package/src/config/provider-globals.ts +9 -9
  124. package/src/config/service-tier.ts +26 -1
  125. package/src/config/settings-schema.ts +209 -51
  126. package/src/config/settings.ts +81 -2
  127. package/src/dap/client.ts +37 -4
  128. package/src/edit/index.ts +49 -12
  129. package/src/eval/py/executor.ts +103 -33
  130. package/src/exec/bash-executor.ts +89 -6
  131. package/src/exec/direnv.ts +145 -0
  132. package/src/export/html/args.ts +20 -0
  133. package/src/export/html/index.ts +45 -47
  134. package/src/export/html/template.css +19 -1
  135. package/src/export/html/template.html +6 -0
  136. package/src/export/html/template.js +21 -0
  137. package/src/export/html/tool-views.generated.js +31 -31
  138. package/src/export/html/web-palette.ts +116 -132
  139. package/src/export/share.ts +249 -49
  140. package/src/extensibility/extensions/loader.ts +29 -1
  141. package/src/extensibility/extensions/runner.ts +6 -0
  142. package/src/extensibility/extensions/types.ts +33 -2
  143. package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
  144. package/src/hindsight/state.ts +64 -6
  145. package/src/internal-urls/local-protocol.ts +2 -1
  146. package/src/internal-urls/memory-protocol.ts +2 -2
  147. package/src/internal-urls/skill-protocol.ts +2 -2
  148. package/src/internal-urls/ssh-protocol.ts +2 -1
  149. package/src/internal-urls/vault-protocol.ts +2 -1
  150. package/src/live/audio-worklet.txt +59 -0
  151. package/src/live/browser-runtime.txt +221 -0
  152. package/src/live/controller.ts +464 -0
  153. package/src/live/prompts/agent-final-message.md +3 -0
  154. package/src/live/prompts/live-instructions.md +23 -0
  155. package/src/live/protocol.test.ts +140 -0
  156. package/src/live/protocol.ts +233 -0
  157. package/src/live/transport.ts +490 -0
  158. package/src/live/visualizer.ts +234 -0
  159. package/src/lsp/client.ts +29 -9
  160. package/src/lsp/index.ts +19 -4
  161. package/src/main.ts +8 -1
  162. package/src/mcp/config-writer.test.ts +43 -0
  163. package/src/mcp/config-writer.ts +109 -82
  164. package/src/mcp/oauth-discovery.ts +10 -2
  165. package/src/mcp/smithery-auth.test.ts +29 -0
  166. package/src/mcp/smithery-auth.ts +3 -2
  167. package/src/memories/index.ts +40 -20
  168. package/src/memory-backend/tool-names.ts +2 -0
  169. package/src/mnemopi/backend.ts +24 -7
  170. package/src/modes/acp/acp-agent.ts +56 -10
  171. package/src/modes/acp/acp-event-mapper.ts +8 -1
  172. package/src/modes/components/agent-hub.ts +1 -1
  173. package/src/modes/components/login-dialog.ts +14 -4
  174. package/src/modes/components/plan-review-overlay.ts +350 -35
  175. package/src/modes/components/settings-defs.ts +23 -1
  176. package/src/modes/components/settings-selector.ts +169 -1
  177. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  178. package/src/modes/components/status-line/component.ts +123 -2
  179. package/src/modes/components/tool-execution.ts +11 -2
  180. package/src/modes/controllers/command-controller.ts +56 -15
  181. package/src/modes/controllers/event-controller.ts +111 -16
  182. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  183. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  184. package/src/modes/controllers/live-command-controller.ts +178 -0
  185. package/src/modes/controllers/mcp-command-controller.ts +10 -1
  186. package/src/modes/controllers/omfg-controller.ts +44 -40
  187. package/src/modes/controllers/selector-controller.ts +18 -10
  188. package/src/modes/controllers/session-focus-controller.ts +6 -1
  189. package/src/modes/interactive-mode.ts +157 -38
  190. package/src/modes/runtime-init.ts +2 -0
  191. package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
  192. package/src/modes/theme/theme.ts +1 -1
  193. package/src/modes/types.ts +3 -1
  194. package/src/modes/utils/context-usage.ts +23 -7
  195. package/src/modes/utils/ui-helpers.ts +10 -5
  196. package/src/plan-mode/plan-files.ts +40 -0
  197. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  198. package/src/prompts/bench/cache-prefix.md +3 -0
  199. package/src/prompts/bench/cache-suffix.md +1 -0
  200. package/src/prompts/system/custom-system-prompt.md +1 -1
  201. package/src/prompts/system/project-prompt.md +8 -1
  202. package/src/prompts/system/subagent-async-pending.md +6 -0
  203. package/src/prompts/system/system-prompt.md +1 -1
  204. package/src/prompts/system/workflow-notice.md +48 -40
  205. package/src/prompts/system/xdev-mount-notice.md +4 -0
  206. package/src/prompts/tools/todo.md +3 -1
  207. package/src/registry/agent-lifecycle.ts +116 -46
  208. package/src/registry/agent-registry.ts +35 -8
  209. package/src/registry/persisted-agents.ts +26 -2
  210. package/src/sdk.ts +210 -136
  211. package/src/secrets/index.ts +127 -4
  212. package/src/secrets/obfuscator.ts +2328 -105
  213. package/src/session/acp-permission-gate.ts +165 -0
  214. package/src/session/agent-session-error-log.test.ts +1 -1
  215. package/src/session/agent-session-events.ts +66 -0
  216. package/src/session/agent-session-types.ts +334 -0
  217. package/src/session/agent-session.ts +1892 -12226
  218. package/src/session/artifacts.ts +6 -5
  219. package/src/session/async-job-delivery.ts +74 -0
  220. package/src/session/bash-runner.ts +326 -0
  221. package/src/session/blob-store.test.ts +56 -0
  222. package/src/session/blob-store.ts +18 -2
  223. package/src/session/checkpoint-entries.ts +81 -0
  224. package/src/session/eval-runner.ts +212 -0
  225. package/src/session/indexed-session-storage.ts +9 -2
  226. package/src/session/irc-bridge.ts +203 -0
  227. package/src/session/messages.ts +263 -1
  228. package/src/session/model-controls.ts +714 -0
  229. package/src/session/prewalk.ts +252 -0
  230. package/src/session/queued-messages.ts +93 -0
  231. package/src/session/redis-session-storage.ts +51 -11
  232. package/src/session/retry-fallback-chains.ts +455 -0
  233. package/src/session/role-models.ts +85 -0
  234. package/src/session/session-advisors.ts +1679 -0
  235. package/src/session/session-context.ts +6 -3
  236. package/src/session/session-entries.ts +8 -0
  237. package/src/session/session-handoff.ts +308 -0
  238. package/src/session/session-history-format.ts +20 -9
  239. package/src/session/session-loader.ts +0 -46
  240. package/src/session/session-maintenance.ts +2983 -0
  241. package/src/session/session-manager.ts +390 -30
  242. package/src/session/session-memory.ts +222 -0
  243. package/src/session/session-provider-boundary.ts +307 -0
  244. package/src/session/session-stats.ts +293 -0
  245. package/src/session/session-tools.ts +943 -0
  246. package/src/session/session-workspace.ts +53 -0
  247. package/src/session/stream-guards.ts +417 -0
  248. package/src/session/todo-tracker.ts +380 -0
  249. package/src/session/ttsr-coordinator.ts +496 -0
  250. package/src/session/turn-recovery.ts +1629 -0
  251. package/src/slash-commands/builtin-registry.ts +92 -11
  252. package/src/slash-commands/helpers/usage-report.ts +22 -3
  253. package/src/system-prompt.ts +41 -17
  254. package/src/task/executor.ts +285 -70
  255. package/src/task/index.ts +98 -105
  256. package/src/task/output-manager.ts +25 -3
  257. package/src/task/persisted-revive.ts +4 -3
  258. package/src/task/structured-subagent.ts +1 -0
  259. package/src/tools/approval.ts +55 -10
  260. package/src/tools/bash-interactive.ts +26 -0
  261. package/src/tools/bash-skill-urls.ts +28 -2
  262. package/src/tools/bash.ts +339 -123
  263. package/src/tools/browser/launch.ts +9 -1
  264. package/src/tools/browser/tab-supervisor.ts +8 -5
  265. package/src/tools/browser.ts +94 -47
  266. package/src/tools/image-gen.ts +20 -28
  267. package/src/tools/image-providers.ts +50 -0
  268. package/src/tools/index.ts +9 -0
  269. package/src/tools/path-utils.ts +49 -2
  270. package/src/tools/read.ts +156 -88
  271. package/src/tools/renderers.ts +7 -1
  272. package/src/tools/todo.ts +101 -11
  273. package/src/tools/vibe.ts +1 -2
  274. package/src/tools/write.ts +51 -1
  275. package/src/tools/xdev.ts +67 -3
  276. package/src/tts/streaming-player.ts +62 -4
  277. package/src/tts/vocalizer.ts +18 -1
  278. package/src/utils/jj.ts +125 -4
  279. package/src/utils/lang-from-path.ts +7 -0
  280. package/src/utils/title-generator.ts +137 -1
  281. package/src/vibe/runtime.ts +852 -72
  282. package/src/web/search/index.ts +3 -6
  283. package/src/web/search/provider.ts +31 -16
  284. package/src/web/search/providers/xai.ts +7 -1
  285. package/src/web/search/types.ts +3 -0
@@ -49,7 +49,14 @@ import {
49
49
  } from "./conflict-detect";
50
50
  import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
51
51
  import { type OutputMeta, outputMeta } from "./output-meta";
52
- import { formatPathRelativeToCwd, isInternalUrlPath, pathTargetsSsh, peelWriteUrlSelector } from "./path-utils";
52
+ import {
53
+ formatPathRelativeToCwd,
54
+ isInternalUrlPath,
55
+ pathTargetsSsh,
56
+ peelWriteUrlSelector,
57
+ probeLiteralPathExists,
58
+ splitPathAndSel,
59
+ } from "./path-utils";
53
60
  import { enforcePlanModeWrite, resolvePlanPath, unwrapHashlineHeaderPath } from "./plan-mode-guard";
54
61
  import {
55
62
  cachedRenderedString,
@@ -116,6 +123,43 @@ function assertWriteTargetAddressable(target: string, router: InternalUrlRouter)
116
123
  );
117
124
  }
118
125
 
126
+ /**
127
+ * Fail closed when a local write target looks like a mis-dispatched read.
128
+ *
129
+ * A read-only step that selects `write` instead of `read` passes the full read
130
+ * expression (`src/foo.tsx:1-260:raw`) as the target. Because a literal colon
131
+ * filename is legal on POSIX (issue #4618), that request otherwise resolves to
132
+ * filesystem creation and reports success, leaving a stray zero-byte file the
133
+ * model cannot recover from — the local analogue of the `xd://` near-miss guard
134
+ * ({@link assertWriteTargetAddressable}, issue #6123).
135
+ *
136
+ * Fires only on the high-confidence combination the report identifies: the tail
137
+ * parses as a read-tool selector, the literal target is missing, and no content
138
+ * was supplied. Non-empty content is the escape hatch — it is never blocked, so
139
+ * a deliberate write to a selector-shaped filename still succeeds. An existing
140
+ * literal path or an ambiguous stat (`"unknown"`: EACCES, transient I/O) also
141
+ * passes through so a real file is never shadowed by the guard.
142
+ */
143
+ function readSelectorForEmptyWrite(target: string, content: string): string | undefined {
144
+ if (content.length > 0) return undefined;
145
+ return splitPathAndSel(target).sel;
146
+ }
147
+
148
+ function throwReadSelectorMisfire(target: string, sel: string): never {
149
+ throw new ToolError(
150
+ `write target '${target}' ends with a read-tool selector ':${sel}' and no such file exists — refusing to create a literal file by that name. ` +
151
+ `If you meant to read it, use read({ path: "${target}" }). ` +
152
+ `If you truly intend to create this file, pass its contents in \`content\` (a non-empty write is never blocked).`,
153
+ );
154
+ }
155
+
156
+ async function assertNotReadSelectorMisfire(target: string, content: string, cwd: string): Promise<void> {
157
+ const sel = readSelectorForEmptyWrite(target, content);
158
+ if (sel === undefined) return;
159
+ if ((await probeLiteralPathExists(target, cwd)) !== "missing") return;
160
+ throwReadSelectorMisfire(target, sel);
161
+ }
162
+
119
163
  const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
120
164
  /**
121
165
  * The head of a per-id directive line — `<id>:` / `<id>=` (optionally `#`-prefixed),
@@ -576,6 +620,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
576
620
  throw new ToolError(error instanceof Error ? error.message : String(error));
577
621
  }
578
622
  }
623
+ const writeTarget = `${resolvedArchivePath.archivePath}:${resolvedArchivePath.archiveSubPath}`;
624
+ const sel = readSelectorForEmptyWrite(writeTarget, content);
625
+ if (sel !== undefined && !entries.has(resolvedArchivePath.archiveSubPath)) {
626
+ throwReadSelectorMisfire(writeTarget, sel);
627
+ }
579
628
  entries.set(resolvedArchivePath.archiveSubPath, content);
580
629
 
581
630
  try {
@@ -1159,6 +1208,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
1159
1208
  return sqliteResult;
1160
1209
  }
1161
1210
 
1211
+ await assertNotReadSelectorMisfire(path, cleanContent, this.session.cwd);
1162
1212
  enforcePlanModeWrite(this.session, path, { op: "create" });
1163
1213
  const absolutePath = resolvePlanPath(this.session, path);
1164
1214
  const batchRequest = getLspBatchRequest(context?.toolCall);
package/src/tools/xdev.ts CHANGED
@@ -60,6 +60,9 @@ export const XDEV_KEEP_TOP_LEVEL: Record<string, true> = {
60
60
  */
61
61
  export const XDEV_TRANSPORT_TOOLS: Record<string, true> = { read: true, write: true };
62
62
 
63
+ /** Controls which mounted-device docs are inlined into the system prompt. */
64
+ export type XdevDocsMode = "inline" | "builtins" | "catalog";
65
+
63
66
  /**
64
67
  * Whether an enabled tool is presented under `xd://` (rather than top-level)
65
68
  * while the `xd://` transport is active. Discoverable tools mount unless they
@@ -170,6 +173,28 @@ function toolSummary(inst: Tool): string {
170
173
  return firstLine?.trim() ?? inst.label ?? inst.name;
171
174
  }
172
175
 
176
+ function promptCatalogSummary(inst: Tool, maxLength?: number): string {
177
+ const summary =
178
+ toolSummary(inst)
179
+ .split("\n")
180
+ .find(line => line.trim().length > 0)
181
+ ?.trim() ?? inst.name;
182
+ if (maxLength === undefined || summary.length <= maxLength) return summary;
183
+ return `${summary.slice(0, maxLength).trimEnd()}…`;
184
+ }
185
+
186
+ /** Compile the `tools.xdevInlineDevices` allowlist once per render, dropping
187
+ * non-string entries so malformed user config cannot break prompt builds. */
188
+ function compileInlineGlobs(patterns: readonly string[]): Bun.Glob[] {
189
+ if (!Array.isArray(patterns)) return [];
190
+ const globs: Bun.Glob[] = [];
191
+ for (const pattern of patterns) {
192
+ if (typeof pattern !== "string" || pattern.length === 0) continue;
193
+ globs.push(new Bun.Glob(pattern));
194
+ }
195
+ return globs;
196
+ }
197
+
173
198
  /** Decode the (possibly partially streamed) inner args JSON string into display args. */
174
199
  function decodeInnerArgs(raw: unknown): Record<string, unknown> {
175
200
  if (typeof raw !== "string" || raw.length === 0) return {};
@@ -230,7 +255,13 @@ export class XdevRegistry {
230
255
 
231
256
  /** `{name, summary}` pairs for prompt templates and /tools display. */
232
257
  entries(): Array<{ name: string; summary: string }> {
233
- return this.list().map(tool => ({ name: tool.name, summary: toolSummary(tool) }));
258
+ return this.list().map(tool => ({
259
+ name: tool.name,
260
+ summary: promptCatalogSummary(
261
+ tool,
262
+ this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined,
263
+ ),
264
+ }));
234
265
  }
235
266
 
236
267
  /** `read xd://` listing with one device per line. */
@@ -273,11 +304,16 @@ export class XdevRegistry {
273
304
  * Dynamic mounts embed at most {@link EXTERNAL_DESCRIPTION_CAP} description
274
305
  * chars (schema always intact); `read xd://<tool>` returns the full text.
275
306
  */
276
- docsAll(): string {
307
+ docsAll(mode: XdevDocsMode = "inline", inlinePatterns: readonly string[] = []): string {
277
308
  const sections: string[] = [];
278
309
  const overflow: Tool[] = [];
310
+ const inlineGlobs = compileInlineGlobs(inlinePatterns);
279
311
  let used = 0;
280
312
  for (const tool of this.list()) {
313
+ if (!this.#shouldInline(tool, mode, inlineGlobs)) {
314
+ overflow.push(tool);
315
+ continue;
316
+ }
281
317
  const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
282
318
  const docs = renderDocs(tool, "##", descriptionCap);
283
319
  if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET) {
@@ -291,7 +327,10 @@ export class XdevRegistry {
291
327
  sections.push(
292
328
  [
293
329
  "## Additional devices (docs on demand)",
294
- ...overflow.map(tool => `- ${XD_URL_PREFIX}${tool.name} — ${toolSummary(tool)}`),
330
+ ...overflow.map(tool => {
331
+ const maxLength = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
332
+ return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxLength)}`;
333
+ }),
295
334
  "",
296
335
  `Read ${XD_URL_PREFIX}<tool> for full docs + JSON schema before first use.`,
297
336
  ].join("\n"),
@@ -300,6 +339,31 @@ export class XdevRegistry {
300
339
  return sections.join("\n\n");
301
340
  }
302
341
 
342
+ /** Docs for selected mounted devices under the configured prompt-doc policy. */
343
+ docsFor(names: Iterable<string>, mode: XdevDocsMode, inlinePatterns: readonly string[] = []): string {
344
+ const sections: string[] = [];
345
+ const inlineGlobs = compileInlineGlobs(inlinePatterns);
346
+ let used = 0;
347
+ for (const name of names) {
348
+ const tool = this.get(name);
349
+ if (!tool || !this.#shouldInline(tool, mode, inlineGlobs)) continue;
350
+ const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
351
+ const docs = renderDocs(tool, "##", descriptionCap);
352
+ if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET)
353
+ continue;
354
+ used += docs.length;
355
+ sections.push(docs);
356
+ }
357
+ return sections.join("\n\n");
358
+ }
359
+
360
+ #shouldInline(tool: Tool, mode: XdevDocsMode, inlineGlobs: readonly Bun.Glob[]): boolean {
361
+ return (
362
+ mode !== "catalog" &&
363
+ (mode === "inline" || this.#builtins.has(tool.name) || inlineGlobs.some(glob => glob.match(tool.name)))
364
+ );
365
+ }
366
+
303
367
  #resolve(name: string): Tool {
304
368
  const inst = this.get(name);
305
369
  if (!inst) {
@@ -31,6 +31,15 @@ const DEFAULT_SAMPLE_RATE = 24_000;
31
31
  const LEAD_SECONDS = 0.6;
32
32
  /** Output gain applied while ducked (the user is speaking over the assistant). */
33
33
  export const DUCK_GAIN = 0.25;
34
+ /**
35
+ * Cap on streamed PCM retained for the nonzero-exit replay. Past this the
36
+ * buffer is dropped: the failure being recovered is a short clip that fits the
37
+ * OS pipe buffer before a broken backend dies, while a backend that consumed
38
+ * minutes of realtime-paced audio was playing it — replaying a whole long
39
+ * utterance would duplicate audio, and unbounded retention (~5.8 MB/min at
40
+ * 24 kHz mono f32) would defeat streaming for long input.
41
+ */
42
+ const REPLAY_RETENTION_SECONDS = 60;
34
43
 
35
44
  /** Injection seam for {@link streamingPlayerCommandsFor} — defaults to real PATH/tools lookups. */
36
45
  export interface StreamingPlayerLookup {
@@ -89,6 +98,20 @@ export function streamingPlayerCommandsFor(
89
98
  return commands;
90
99
  }
91
100
 
101
+ /**
102
+ * Test seams for {@link StreamingAudioPlayer}: override backend discovery and
103
+ * the per-file fallback so playback logic can be exercised without a real audio
104
+ * device. Both default to the platform lookup and {@link playAudioFile}.
105
+ */
106
+ export interface StreamingPlayerOptions {
107
+ /** Ordered backend commands for a sample rate; defaults to {@link streamingPlayerCommandsFor}. */
108
+ commandsFor?: (sampleRate: number) => PlayerCommand[];
109
+ /** Per-file fallback playback; defaults to {@link playAudioFile}. */
110
+ playAudio?: (wavPath: string, signal: AbortSignal) => Promise<void>;
111
+ /** Max seconds of streamed PCM retained for the nonzero-exit replay; defaults to {@link REPLAY_RETENTION_SECONDS}. */
112
+ replayRetentionSeconds?: number;
113
+ }
114
+
92
115
  /**
93
116
  * Single-session gapless player. Lifecycle: {@link start} once, {@link write}
94
117
  * chunks in order, then {@link end} to drain or {@link stop} to abort. Not
@@ -111,6 +134,18 @@ export class StreamingAudioPlayer {
111
134
  #abortController = new AbortController();
112
135
  #wake: (() => void) | null = null;
113
136
  #drain: Promise<void> = Promise.resolve();
137
+ readonly #commandsFor: (sampleRate: number) => PlayerCommand[];
138
+ readonly #playAudio: (wavPath: string, signal: AbortSignal) => Promise<void>;
139
+ readonly #replayRetentionSec: number;
140
+ /** Streamed PCM retained for this utterance so a failed backend can be replayed via file playback. */
141
+ #played: Float32Array[] = [];
142
+ #playedSec = 0;
143
+
144
+ constructor(options: StreamingPlayerOptions = {}) {
145
+ this.#commandsFor = options.commandsFor ?? (rate => streamingPlayerCommandsFor(process.platform, rate));
146
+ this.#playAudio = options.playAudio ?? ((wavPath, signal) => playAudioFile(wavPath, { signal }));
147
+ this.#replayRetentionSec = options.replayRetentionSeconds ?? REPLAY_RETENTION_SECONDS;
148
+ }
114
149
 
115
150
  /** Pick a backend and begin draining. Idempotent; the first call's rate wins. */
116
151
  start(sampleRate: number): void {
@@ -146,6 +181,7 @@ export class StreamingAudioPlayer {
146
181
  if (this.#stopped) return;
147
182
  this.#stopped = true;
148
183
  this.#queue.length = 0;
184
+ this.#played.length = 0;
149
185
  this.#abortController.abort();
150
186
  this.#signal();
151
187
  try {
@@ -168,7 +204,7 @@ export class StreamingAudioPlayer {
168
204
  * in-flight chunk.
169
205
  */
170
206
  #spawnStream(): boolean {
171
- this.#candidates ??= streamingPlayerCommandsFor(process.platform, this.#sampleRate);
207
+ this.#candidates ??= this.#commandsFor(this.#sampleRate);
172
208
  for (let command = this.#candidates.shift(); command; command = this.#candidates.shift()) {
173
209
  const { cmd, args } = command;
174
210
  try {
@@ -213,6 +249,12 @@ export class StreamingAudioPlayer {
213
249
  continue;
214
250
  }
215
251
  if (this.#mode === "stream") {
252
+ if (this.#playedSec <= this.#replayRetentionSec) {
253
+ this.#played.push(chunk);
254
+ this.#playedSec += chunk.length / this.#sampleRate;
255
+ // Over the cap: drop retention for the rest of the utterance.
256
+ if (this.#playedSec > this.#replayRetentionSec) this.#played.length = 0;
257
+ }
216
258
  // Pace writes so the player buffers ~LEAD_SECONDS, no more, keeping
217
259
  // ducking and stop responsive instead of locked behind buffered audio.
218
260
  const ahead = this.#writtenSec - (performance.now() - this.#startedAt) / 1000;
@@ -242,11 +284,27 @@ export class StreamingAudioPlayer {
242
284
  try {
243
285
  await this.#sink?.end();
244
286
  } catch {}
245
- if (this.#proc) {
287
+ const proc = this.#proc;
288
+ let exitCode: number | null = null;
289
+ if (proc) {
246
290
  try {
247
- await this.#proc.exited;
291
+ exitCode = await proc.exited;
248
292
  } catch {}
249
293
  }
294
+ // A streaming backend that exits nonzero never opened its audio
295
+ // device (e.g. the bundled ffmpeg built without pulse/alsa output).
296
+ // For a short single-segment clip the pipe write succeeds before
297
+ // that death and #inputClosed is already set, so neither the
298
+ // broken-pipe replay nor the early-exit handler advances backends.
299
+ // Replay the buffered utterance through per-file playback so it
300
+ // still reaches the speakers.
301
+ if (!this.#stopped && proc && exitCode !== 0) {
302
+ this.#mode = "file";
303
+ for (const chunk of this.#played) {
304
+ if (this.#stopped) break;
305
+ await this.#playFile(chunk);
306
+ }
307
+ }
250
308
  }
251
309
  } catch (error) {
252
310
  logger.debug("tts: streaming player drain failed", {
@@ -291,7 +349,7 @@ export class StreamingAudioPlayer {
291
349
  const wavPath = path.join(os.tmpdir(), `omp-speech-${Snowflake.next()}.wav`);
292
350
  try {
293
351
  await fs.writeFile(wavPath, encodeWav(this.#scaled(pcm), this.#sampleRate));
294
- if (!this.#stopped) await playAudioFile(wavPath, { signal: this.#abortController.signal });
352
+ if (!this.#stopped) await this.#playAudio(wavPath, this.#abortController.signal);
295
353
  } catch (error) {
296
354
  logger.debug("tts: file playback failed", {
297
355
  error: error instanceof Error ? error.message : String(error),
@@ -100,6 +100,8 @@ export class Vocalizer {
100
100
  #chain: Promise<void> = Promise.resolve();
101
101
  /** Whether the user is currently speaking; new sessions open ducked. */
102
102
  #ducked = false;
103
+ /** Active scopes that suppress local TTS while another speech surface owns audio. */
104
+ #suspensions = 0;
103
105
  /** Available rewrite slots; blocks queue when exhausted. */
104
106
  #rewriteSlots = MAX_REWRITES_IN_FLIGHT;
105
107
  #slotWaiters: Array<() => void> = [];
@@ -114,6 +116,21 @@ export class Vocalizer {
114
116
  this.#enhancer = enhancer;
115
117
  }
116
118
 
119
+ /**
120
+ * Suppress new vocalization until the returned idempotent release function runs.
121
+ * Existing synthesis and playback stop immediately; nested scopes release independently.
122
+ */
123
+ suspend(): () => void {
124
+ this.#suspensions += 1;
125
+ this.clear();
126
+ let released = false;
127
+ return () => {
128
+ if (released) return;
129
+ released = true;
130
+ this.#suspensions = Math.max(0, this.#suspensions - 1);
131
+ };
132
+ }
133
+
117
134
  /**
118
135
  * Stream a delta of assistant text into the pipeline. No-op when
119
136
  * vocalization is disabled. The synthesis session (worker, player) is only
@@ -123,7 +140,7 @@ export class Vocalizer {
123
140
  * pipeline (enhanced vs mechanical) is latched per utterance.
124
141
  */
125
142
  pushDelta(text: string): void {
126
- if (!settings.get("speech.enabled")) return;
143
+ if (this.#suspensions > 0 || !settings.get("speech.enabled")) return;
127
144
  if (!text) return;
128
145
  if (this.#enhanced || (!this.#speakable && this.#enhancer && settings.get("speech.enhanced"))) {
129
146
  this.#pushEnhanced(text);
package/src/utils/jj.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as fs from "node:fs/promises";
1
+ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { $which } from "@oh-my-pi/pi-utils";
4
4
  import { LRUCache } from "lru-cache/raw";
@@ -63,6 +63,8 @@ export class JjCommandError extends Error {
63
63
  // ════════════════════════════════════════════════════════════════════════════
64
64
  // Internal: Core execution
65
65
  // ════════════════════════════════════════════════════════════════════════════
66
+ const WORKING_COPY_LABEL_REVSET = "@ | heads(::@ & bookmarks())";
67
+ const WORKING_COPY_LABEL_TEMPLATE = 'change_id.shortest(8) ++ "|" ++ local_bookmarks ++ "\\n"';
66
68
 
67
69
  function ensureAvailable(): void {
68
70
  if (!$which("jj")) {
@@ -121,6 +123,19 @@ async function runText(cwd: string, args: readonly string[], options: CommandOpt
121
123
  return (await runChecked(cwd, args, options)).stdout;
122
124
  }
123
125
 
126
+ async function runOptionalText(
127
+ cwd: string,
128
+ args: readonly string[],
129
+ options: CommandOptions = {},
130
+ ): Promise<string | null> {
131
+ try {
132
+ const result = await jj(cwd, args, options);
133
+ return result.exitCode === 0 ? result.stdout : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
124
139
  function splitLines(text: string): string[] {
125
140
  return text
126
141
  .split("\n")
@@ -135,6 +150,30 @@ function buildDiffArgs(options: DiffOptions): string[] {
135
150
  return args;
136
151
  }
137
152
 
153
+ function parseWorkingCopyLabel(raw: string): string | null {
154
+ let changeId: string | null = null;
155
+ for (const line of raw.split("\n")) {
156
+ const sep = line.indexOf("|");
157
+ const change = (sep === -1 ? line : line.slice(0, sep)).trim();
158
+ const bookmarks = sep === -1 ? "" : line.slice(sep + 1).trim();
159
+ if (changeId === null && change) changeId = change;
160
+ if (bookmarks) return bookmarks.replace(/\s+/g, " ");
161
+ }
162
+ return changeId;
163
+ }
164
+
165
+ function parseStatusSummary(raw: string): git.GitStatusSummary {
166
+ let unstaged = 0;
167
+ let untracked = 0;
168
+ for (const line of raw.split("\n")) {
169
+ const type = line.trim()[0];
170
+ if (!type) continue;
171
+ if (type === "A") untracked++;
172
+ else unstaged++;
173
+ }
174
+ return { staged: 0, unstaged, untracked };
175
+ }
176
+
138
177
  // ════════════════════════════════════════════════════════════════════════════
139
178
  // Internal: Repository resolution
140
179
  // ════════════════════════════════════════════════════════════════════════════
@@ -153,7 +192,16 @@ async function hasJjWorkspaceMetadata(dir: string): Promise<boolean> {
153
192
  // of the default workspace. Either form is a real workspace, so match on
154
193
  // `.jj/repo` presence rather than the inner `store/` directory.
155
194
  try {
156
- await fs.stat(path.join(dir, ".jj", "repo"));
195
+ await fs.promises.stat(path.join(dir, ".jj", "repo"));
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ function hasJjWorkspaceMetadataSync(dir: string): boolean {
203
+ try {
204
+ fs.statSync(path.join(dir, ".jj", "repo"));
157
205
  return true;
158
206
  } catch {
159
207
  return false;
@@ -180,6 +228,21 @@ async function findWorkspaceRoot(cwd: string): Promise<string | undefined> {
180
228
  return undefined;
181
229
  }
182
230
 
231
+ function findWorkspaceRootSync(cwd: string): string | undefined {
232
+ const key = path.resolve(cwd);
233
+ if (workspaceRootCache.has(key)) return workspaceRootCache.get(key)?.root;
234
+
235
+ for (let dir: string | undefined = key; dir; dir = parentOf(dir)) {
236
+ if (hasJjWorkspaceMetadataSync(dir)) {
237
+ workspaceRootCache.set(key, { root: dir });
238
+ return dir;
239
+ }
240
+ }
241
+
242
+ workspaceRootCache.set(key, {});
243
+ return undefined;
244
+ }
245
+
183
246
  /**
184
247
  * Resolve the `.jj/repo` directory backing a workspace root, following the file
185
248
  * indirection used by non-default workspaces. `jj workspace add` writes a FILE at
@@ -190,8 +253,8 @@ async function findWorkspaceRoot(cwd: string): Promise<string | undefined> {
190
253
  async function resolveRepoDir(root: string): Promise<string> {
191
254
  const jjDir = path.join(root, ".jj");
192
255
  const repoPath = path.join(jjDir, "repo");
193
- if ((await fs.stat(repoPath)).isFile()) {
194
- const target = (await fs.readFile(repoPath, "utf8")).trim();
256
+ if ((await fs.promises.stat(repoPath)).isFile()) {
257
+ const target = (await fs.promises.readFile(repoPath, "utf8")).trim();
195
258
  return path.resolve(jjDir, target);
196
259
  }
197
260
  return repoPath;
@@ -221,6 +284,56 @@ export const diff = Object.assign(
221
284
  },
222
285
  );
223
286
 
287
+ // ════════════════════════════════════════════════════════════════════════════
288
+ // API: working copy
289
+ // ════════════════════════════════════════════════════════════════════════════
290
+
291
+ /** Jujutsu working-copy metadata used by status displays. */
292
+ export const workingCopy = {
293
+ /**
294
+ * Label `@` with its nearest bookmark, falling back to its short change ID.
295
+ * Returns `null` when `jj` is unavailable or the query fails.
296
+ */
297
+ async label(cwd: string, signal?: AbortSignal): Promise<string | null> {
298
+ const raw = await runOptionalText(
299
+ cwd,
300
+ [
301
+ "log",
302
+ "--no-graph",
303
+ "--ignore-working-copy",
304
+ "-r",
305
+ WORKING_COPY_LABEL_REVSET,
306
+ "-T",
307
+ WORKING_COPY_LABEL_TEMPLATE,
308
+ ],
309
+ { signal },
310
+ );
311
+ return raw === null ? null : parseWorkingCopyLabel(raw);
312
+ },
313
+
314
+ /** Parse working-copy label query output. */
315
+ parseLabel: parseWorkingCopyLabel,
316
+ };
317
+
318
+ // ════════════════════════════════════════════════════════════════════════════
319
+ // API: status
320
+ // ════════════════════════════════════════════════════════════════════════════
321
+
322
+ /** Jujutsu working-copy status derived from the changes in `@`. */
323
+ export const status = {
324
+ /**
325
+ * Count changes in `@` relative to its parent using the Git status shape.
326
+ * Jujutsu has no index, so `staged` is always zero.
327
+ */
328
+ async summary(cwd: string, signal?: AbortSignal): Promise<git.GitStatusSummary | null> {
329
+ const raw = await runOptionalText(cwd, ["diff", "-r", "@", "--summary", "--ignore-working-copy"], { signal });
330
+ return raw === null ? null : parseStatusSummary(raw);
331
+ },
332
+
333
+ /** Parse `jj diff --summary` output into status counts. */
334
+ parse: parseStatusSummary,
335
+ };
336
+
224
337
  // ════════════════════════════════════════════════════════════════════════════
225
338
  // API: repo
226
339
  // ════════════════════════════════════════════════════════════════════════════
@@ -231,6 +344,14 @@ export const repo = {
231
344
  workspaceRootCache.clear();
232
345
  },
233
346
 
347
+ /**
348
+ * Resolve the current workspace root synchronously from on-disk metadata.
349
+ * Intended for render paths that cannot await filesystem I/O.
350
+ */
351
+ rootSync(cwd: string): string | null {
352
+ return findWorkspaceRootSync(cwd) ?? null;
353
+ },
354
+
234
355
  /** Resolve the current Jujutsu workspace root, or `null` when `cwd` is not in a JJ repository. */
235
356
  async root(cwd: string): Promise<string | null> {
236
357
  return (await findWorkspaceRoot(cwd)) ?? null;
@@ -102,6 +102,9 @@ const EXTENSION_LANG: Record<string, readonly [string, string]> = {
102
102
  md: ["markdown", "markdown"],
103
103
  markdown: ["markdown", "markdown"],
104
104
  mdx: ["markdown", "markdown"],
105
+ mdc: ["markdown", "markdown"],
106
+ mkd: ["markdown", "markdown"],
107
+ mdown: ["markdown", "markdown"],
105
108
  rst: ["restructuredtext", "restructuredtext"],
106
109
  adoc: ["asciidoc", "asciidoc"],
107
110
  tex: ["latex", "latex"],
@@ -217,6 +220,10 @@ export function getLanguageFromPath(filePath: string): string | undefined {
217
220
  return undefined;
218
221
  }
219
222
 
223
+ export function isMarkdownPath(filePath: string): boolean {
224
+ return getLanguageFromPath(filePath) === "markdown";
225
+ }
226
+
220
227
  /**
221
228
  * LSP language identifier; falls back to `plaintext`.
222
229
  */