@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
package/src/tools/read.ts CHANGED
@@ -11,12 +11,14 @@ import type {
11
11
  ToolTier,
12
12
  } from "@oh-my-pi/pi-agent-core";
13
13
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
14
- import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
14
+ import { type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
15
15
  import type { Component } from "@oh-my-pi/pi-tui";
16
16
  import { Text } from "@oh-my-pi/pi-tui";
17
17
  import {
18
18
  getRemoteDir,
19
19
  type ImageMetadata,
20
+ isEexist,
21
+ isEnotempty,
20
22
  isProbablyBinary,
21
23
  logger,
22
24
  prompt,
@@ -40,7 +42,7 @@ import { InternalUrlRouter, resolveLocalUrlToFile, resolveLocalUrlToPath } from
40
42
  import { type ResolvedArtifactFile, resolveArtifactFile } from "../internal-urls/artifact-protocol";
41
43
  import { parseInternalUrl } from "../internal-urls/parse";
42
44
  import type { InternalUrl } from "../internal-urls/types";
43
- import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
45
+ import { getLanguageFromPath, isMarkdownPath, type Theme } from "../modes/theme/theme";
44
46
  import readDescription from "../prompts/tools/read.md" with { type: "text" };
45
47
  import type { ToolSession } from "../sdk";
46
48
  import {
@@ -94,6 +96,7 @@ import {
94
96
  } from "./output-meta";
95
97
  import {
96
98
  expandPath,
99
+ findUniqueWorkspaceSuffix,
97
100
  formatPathRelativeToCwd,
98
101
  isReadableUrlPath,
99
102
  type LineRange,
@@ -155,12 +158,13 @@ const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
155
158
  const MAX_SUMMARY_LINES = 20_000;
156
159
  const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
157
160
  /**
158
- * Per-line column cap for file reads. Lines wider than the value of
159
- * `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
160
- * on disk is unchanged. Shared with the streaming sink path so one setting
161
- * covers `bash`/`ssh`/`python`/`js eval` and `read` uniformly.
161
+ * Prose files (Markdown flavors and plain text) skip code-block summarization
162
+ * unless `read.summarize.prose` opts them in.
162
163
  */
163
- const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
164
+ function isProseSummaryPath(filePath: string): boolean {
165
+ return isMarkdownPath(filePath) || path.extname(filePath).toLowerCase() === ".txt";
166
+ }
167
+
164
168
  // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
165
169
  const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
166
170
 
@@ -639,7 +643,6 @@ async function streamLinesFromFile(
639
643
 
640
644
  // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
641
645
  const MAX_IMAGE_SIZE = MAX_IMAGE_INPUT_BYTES;
642
- const GLOB_TIMEOUT_MS = 5000;
643
646
 
644
647
  function isNotFoundError(error: unknown): boolean {
645
648
  if (!error || typeof error !== "object") return false;
@@ -647,61 +650,6 @@ function isNotFoundError(error: unknown): boolean {
647
650
  return code === "ENOENT" || code === "ENOTDIR";
648
651
  }
649
652
 
650
- /**
651
- * Escape glob metacharacters so a literal path (e.g. `foo[1].ts`) interpolated
652
- * into a suffix-glob pattern matches itself. Each metachar is wrapped in a
653
- * character class (the native glob engine rewrites `\` to `/`, so backslash
654
- * escaping is unavailable). `]`/`}` need no escaping once their openers are
655
- * neutralized — unmatched closers are literal.
656
- */
657
- function escapeGlobMetachars(value: string): string {
658
- return value.replace(/[*?[{]/g, "[$&]");
659
- }
660
-
661
- /**
662
- * Attempt to resolve a non-existent path by finding a unique suffix match within the workspace.
663
- * Uses a glob suffix pattern so the native engine handles matching directly.
664
- * Returns null when 0 or >1 candidates match (ambiguous = no auto-resolution).
665
- */
666
- async function findUniqueSuffixMatch(
667
- rawPath: string,
668
- cwd: string,
669
- signal?: AbortSignal,
670
- ): Promise<{ absolutePath: string; displayPath: string } | null> {
671
- const normalized = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
672
- if (!normalized) return null;
673
- const pattern = `**/${escapeGlobMetachars(normalized)}`;
674
-
675
- const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
676
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
677
-
678
- let matches: string[];
679
- try {
680
- const result = await untilAborted(combinedSignal, () =>
681
- glob({
682
- pattern,
683
- path: cwd,
684
- // No fileType filter: matches both files and directories
685
- hidden: true,
686
- }),
687
- );
688
- matches = result.matches.map(m => m.path);
689
- } catch (error) {
690
- if (error instanceof Error && error.name === "AbortError") {
691
- if (!signal?.aborted) return null; // timeout — give up silently
692
- throw new ToolAbortError();
693
- }
694
- return null;
695
- }
696
-
697
- if (matches.length !== 1) return null;
698
-
699
- return {
700
- absolutePath: path.resolve(cwd, matches[0]),
701
- displayPath: matches[0],
702
- };
703
- }
704
-
705
653
  function decodeUtf8Text(bytes: Uint8Array): string | null {
706
654
  if (bytes.indexOf(0) !== -1) return null;
707
655
 
@@ -721,6 +669,22 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
721
669
  const PDF_IMAGE_PLACEHOLDER_RE = /<!--\s*image:\s*([^\s<>]+)(.*?)-->/g;
722
670
  const PDF_IMAGE_MEMBER_RE = /^(.*\.pdf):(.*)$/i;
723
671
  const PDF_IMAGE_MEMBER_EXTENSION_RE = /\.png$/i;
672
+ const PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH = 96;
673
+
674
+ interface PdfImageSnapshot {
675
+ directory: string;
676
+ filePath: string;
677
+ digest: string;
678
+ }
679
+
680
+ interface PdfImageExtraction {
681
+ controller: AbortController;
682
+ promise: Promise<string>;
683
+ settled: boolean;
684
+ waiters: number;
685
+ }
686
+
687
+ const pdfImageExtractions = new Map<string, PdfImageExtraction>();
724
688
 
725
689
  function pdfImageMemberPath(pdfPath: string, imageId: string): string {
726
690
  const member = PDF_IMAGE_MEMBER_EXTENSION_RE.test(imageId) ? imageId : `${imageId}.png`;
@@ -779,7 +743,6 @@ export interface ReadToolDetails {
779
743
  /** Paths recovered from a delimited read argument; used only by the TUI to render one call as multiple read rows. */
780
744
  displayReadTargets?: string[];
781
745
  }
782
-
783
746
  type ReadParams = ReadToolInput;
784
747
 
785
748
  /** Parsed representation of a path-embedded selector. */
@@ -991,7 +954,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
991
954
  }
992
955
 
993
956
  /**
994
- * Memoized {@link findUniqueSuffixMatch} for a single read call. A missing
957
+ * Memoized {@link findUniqueWorkspaceSuffix} for a single read call. A missing
995
958
  * path with archive/sqlite extensions probes the workspace once per stage
996
959
  * (archive candidates, sqlite candidates, plain path) — each glob carries a
997
960
  * 5s timeout, so repeated lookups of the same string stack into a long
@@ -1004,7 +967,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1004
967
  ): Promise<{ absolutePath: string; displayPath: string } | null> {
1005
968
  const hit = cache.get(rawPath);
1006
969
  if (hit !== undefined) return hit;
1007
- const result = await findUniqueSuffixMatch(rawPath, this.session.cwd, signal);
970
+ const result = await findUniqueWorkspaceSuffix(rawPath, this.session.cwd, signal);
1008
971
  cache.set(rawPath, result);
1009
972
  return result;
1010
973
  }
@@ -1106,7 +1069,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1106
1069
  return null;
1107
1070
  }
1108
1071
 
1109
- #pdfImageCacheDir(absolutePdfPath: string): string {
1072
+ #pdfImageCacheDir(absolutePdfPath: string, contentDigest: string): string {
1110
1073
  const artifactsDir = this.session.getArtifactsDir?.();
1111
1074
  let root = artifactsDir ?? undefined;
1112
1075
  if (root === undefined) {
@@ -1115,8 +1078,28 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1115
1078
  ? sessionFile.slice(0, -6)
1116
1079
  : path.join(os.tmpdir(), "omp-read-pdf-images");
1117
1080
  }
1118
- const basename = path.basename(absolutePdfPath).replace(/[^A-Za-z0-9._-]/g, "_");
1119
- return path.join(root, "read-pdf-images", `${basename}-${Bun.hash(absolutePdfPath).toString(36)}`);
1081
+ const basename = path
1082
+ .basename(absolutePdfPath)
1083
+ .replace(/[^A-Za-z0-9._-]/g, "_")
1084
+ .slice(0, PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH);
1085
+ const pathDigest = Bun.hash(absolutePdfPath).toString(36);
1086
+ return path.join(root, "read-pdf-images", `${basename}-${pathDigest}-${contentDigest}`);
1087
+ }
1088
+
1089
+ async #snapshotPdfSource(absolutePdfPath: string, signal?: AbortSignal): Promise<PdfImageSnapshot> {
1090
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "omp-read-pdf-"));
1091
+ try {
1092
+ const bytes = await untilAborted(signal, () => Bun.file(absolutePdfPath).bytes());
1093
+ signal?.throwIfAborted();
1094
+ const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
1095
+ const filePath = path.join(directory, "source.pdf");
1096
+ await Bun.write(filePath, bytes);
1097
+ signal?.throwIfAborted();
1098
+ return { directory, filePath, digest };
1099
+ } catch (error) {
1100
+ await fs.rm(directory, { recursive: true, force: true });
1101
+ throw error;
1102
+ }
1120
1103
  }
1121
1104
 
1122
1105
  async #listPdfImageMembers(imageDir: string): Promise<string[]> {
@@ -1133,8 +1116,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1133
1116
  }
1134
1117
  }
1135
1118
 
1136
- async #ensurePdfImageCache(absolutePdfPath: string, signal?: AbortSignal): Promise<string> {
1137
- const imageDir = this.#pdfImageCacheDir(absolutePdfPath);
1119
+ async #extractPdfImages(snapshot: PdfImageSnapshot, imageDir: string, signal: AbortSignal): Promise<string> {
1138
1120
  const markerPath = path.join(imageDir, ".extracted");
1139
1121
  try {
1140
1122
  await fs.stat(markerPath);
@@ -1143,15 +1125,74 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1143
1125
  if (!isNotFoundError(error)) throw error;
1144
1126
  }
1145
1127
 
1146
- await fs.rm(imageDir, { recursive: true, force: true });
1147
- await fs.mkdir(imageDir, { recursive: true });
1148
- const result = await convertFileWithMarkit(absolutePdfPath, signal, { imageDir });
1149
- if (!result.ok) {
1150
- await fs.rm(imageDir, { recursive: true, force: true });
1151
- throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`);
1128
+ await fs.mkdir(path.dirname(imageDir), { recursive: true });
1129
+ const stagingDir = await fs.mkdtemp(`${imageDir}.tmp-`);
1130
+ let published = false;
1131
+ try {
1132
+ const result = await convertFileWithMarkit(snapshot.filePath, signal, { imageDir: stagingDir });
1133
+ if (!result.ok) {
1134
+ throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`);
1135
+ }
1136
+ await Bun.write(path.join(stagingDir, ".extracted"), "ok");
1137
+ try {
1138
+ await fs.rename(stagingDir, imageDir);
1139
+ published = true;
1140
+ } catch (error) {
1141
+ if (!isEexist(error) && !isEnotempty(error)) throw error;
1142
+ try {
1143
+ await fs.stat(markerPath);
1144
+ } catch (markerError) {
1145
+ if (isNotFoundError(markerError)) throw error;
1146
+ throw markerError;
1147
+ }
1148
+ }
1149
+ return imageDir;
1150
+ } finally {
1151
+ if (!published) await fs.rm(stagingDir, { recursive: true, force: true });
1152
+ }
1153
+ }
1154
+
1155
+ #createPdfImageExtraction(snapshot: PdfImageSnapshot, imageDir: string): PdfImageExtraction {
1156
+ const controller = new AbortController();
1157
+ const promise = this.#extractPdfImages(snapshot, imageDir, controller.signal).finally(() =>
1158
+ fs.rm(snapshot.directory, { recursive: true, force: true }),
1159
+ );
1160
+ const extraction: PdfImageExtraction = { controller, promise, settled: false, waiters: 0 };
1161
+ const settle = () => {
1162
+ extraction.settled = true;
1163
+ if (pdfImageExtractions.get(imageDir) === extraction) pdfImageExtractions.delete(imageDir);
1164
+ };
1165
+ void promise.then(settle, settle);
1166
+ return extraction;
1167
+ }
1168
+
1169
+ async #waitForPdfImageExtraction(extraction: PdfImageExtraction, signal: AbortSignal | undefined): Promise<string> {
1170
+ extraction.waiters++;
1171
+ try {
1172
+ return await untilAborted(signal, extraction.promise);
1173
+ } finally {
1174
+ extraction.waiters--;
1175
+ if (extraction.waiters === 0 && !extraction.settled) {
1176
+ extraction.controller.abort();
1177
+ try {
1178
+ await extraction.promise;
1179
+ } catch {}
1180
+ }
1152
1181
  }
1153
- await Bun.write(markerPath, "ok");
1154
- return imageDir;
1182
+ }
1183
+
1184
+ async #ensurePdfImageCache(absolutePdfPath: string, signal?: AbortSignal): Promise<string> {
1185
+ const snapshot = await this.#snapshotPdfSource(absolutePdfPath, signal);
1186
+ const imageDir = this.#pdfImageCacheDir(absolutePdfPath, snapshot.digest);
1187
+ const existing = pdfImageExtractions.get(imageDir);
1188
+ if (existing && !existing.settled && !existing.controller.signal.aborted) {
1189
+ await fs.rm(snapshot.directory, { recursive: true, force: true });
1190
+ return this.#waitForPdfImageExtraction(existing, signal);
1191
+ }
1192
+
1193
+ const extraction = this.#createPdfImageExtraction(snapshot, imageDir);
1194
+ pdfImageExtractions.set(imageDir, extraction);
1195
+ return this.#waitForPdfImageExtraction(extraction, signal);
1155
1196
  }
1156
1197
 
1157
1198
  async #readPdfImageMember(
@@ -1598,7 +1639,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1598
1639
  try {
1599
1640
  const bridgeText = await bridgePromise;
1600
1641
  const bridgeResult = this.#buildInMemoryMultiRangeResult(bridgeText, ranges, {
1601
- details: { resolvedPath: absolutePath, suffixResolution },
1642
+ details: this.#markMarkdownContentType({ resolvedPath: absolutePath, suffixResolution }, absolutePath),
1602
1643
  sourcePath: absolutePath,
1603
1644
  entityLabel: "file",
1604
1645
  raw: rawSelector,
@@ -1781,10 +1822,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1781
1822
  const archive = await openArchive(resolvedArchivePath.absolutePath);
1782
1823
  throwIfAborted(signal);
1783
1824
 
1784
- const details: ReadToolDetails = {
1785
- resolvedPath: resolvedArchivePath.absolutePath,
1786
- suffixResolution: resolvedArchivePath.suffixResolution,
1787
- };
1825
+ const details: ReadToolDetails = this.#markMarkdownContentType(
1826
+ {
1827
+ resolvedPath: resolvedArchivePath.absolutePath,
1828
+ suffixResolution: resolvedArchivePath.suffixResolution,
1829
+ },
1830
+ resolvedArchivePath.archiveSubPath,
1831
+ );
1788
1832
 
1789
1833
  let archiveSubPath = resolvedArchivePath.archiveSubPath;
1790
1834
  let sel = parsedSel;
@@ -1995,6 +2039,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1995
2039
  return bridge.readTextFile({ path: absolutePath, ...options });
1996
2040
  }
1997
2041
 
2042
+ /**
2043
+ * Tag Markdown reads for the TUI's formatted preview, gated on the opt-in
2044
+ * `read.renderMarkdown` setting. Off by default; when disabled, no local
2045
+ * read is tagged `text/markdown`, so the renderer output is identical to
2046
+ * the pre-setting behavior. Internal-URL reads keep their protocol-supplied
2047
+ * `contentType` and render as Markdown regardless of the setting.
2048
+ */
2049
+ #markMarkdownContentType(details: ReadToolDetails, filePath: string): ReadToolDetails {
2050
+ if (!details.contentType && this.session.settings.get("read.renderMarkdown") && isMarkdownPath(filePath)) {
2051
+ details.contentType = "text/markdown";
2052
+ }
2053
+ return details;
2054
+ }
2055
+
1998
2056
  async #trySummarize(absolutePath: string, fileSize: number, signal?: AbortSignal): Promise<SummaryResult | null> {
1999
2057
  if (fileSize > MAX_SUMMARY_BYTES) return null;
2000
2058
 
@@ -2419,14 +2477,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2419
2477
  // because only `truncateHead` was being applied.
2420
2478
  if (isMultiRange(parsed) && parsed.kind === "lines") {
2421
2479
  return this.#buildInMemoryMultiRangeResult(renderedContent, parsed.ranges, {
2422
- details: { resolvedPath: absolutePath },
2480
+ details: {
2481
+ resolvedPath: absolutePath,
2482
+ contentType: this.session.settings.get("read.renderMarkdown") ? "text/markdown" : undefined,
2483
+ },
2423
2484
  sourcePath: absolutePath,
2424
2485
  entityLabel: "document",
2425
2486
  });
2426
2487
  }
2427
2488
  const { offset, limit } = selToOffsetLimit(parsed);
2428
2489
  return this.#buildInMemoryTextResult(renderedContent, offset, limit, {
2429
- details: { resolvedPath: absolutePath },
2490
+ details: {
2491
+ resolvedPath: absolutePath,
2492
+ contentType: this.session.settings.get("read.renderMarkdown") ? "text/markdown" : undefined,
2493
+ },
2430
2494
  sourcePath: absolutePath,
2431
2495
  entityLabel: "document",
2432
2496
  raw: isRawSelector(parsed),
@@ -2459,7 +2523,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2459
2523
  if (
2460
2524
  parsed.kind === "none" &&
2461
2525
  this.session.settings.get("read.summarize.enabled") &&
2462
- (this.session.settings.get("read.summarize.prose") || !PROSE_SUMMARY_EXTENSIONS.has(ext))
2526
+ (this.session.settings.get("read.summarize.prose") || !isProseSummaryPath(absolutePath))
2463
2527
  ) {
2464
2528
  const summary = await this.#trySummarize(absolutePath, fileSize, signal);
2465
2529
  if (summary?.parsed && summary.elided) {
@@ -2519,7 +2583,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2519
2583
  try {
2520
2584
  const bridgeText = await bridgePromise;
2521
2585
  const bridgeResult = this.#buildInMemoryTextResult(bridgeText, offset, limit, {
2522
- details: { resolvedPath: absolutePath, suffixResolution },
2586
+ details: this.#markMarkdownContentType(
2587
+ { resolvedPath: absolutePath, suffixResolution },
2588
+ absolutePath,
2589
+ ),
2523
2590
  sourcePath: absolutePath,
2524
2591
  entityLabel: "file",
2525
2592
  raw: isRawSelector(parsed),
@@ -2818,6 +2885,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2818
2885
  }
2819
2886
  }
2820
2887
 
2888
+ this.#markMarkdownContentType(details, absolutePath);
2821
2889
  if (suffixResolution) {
2822
2890
  details.suffixResolution = suffixResolution;
2823
2891
  // Inline resolution notice into first text block so the model sees the actual path
@@ -90,7 +90,13 @@ export const toolRenderers: Record<string, ToolRenderer> = {
90
90
  grep: grepToolRenderer as ToolRenderer,
91
91
  lsp: lspToolRenderer as ToolRenderer,
92
92
  inspect_image: inspectImageToolRenderer as ToolRenderer,
93
- hub: hubToolRenderer as ToolRenderer,
93
+ // Lazy getter: `hubToolRenderer` lives in a module whose deps (messaging →
94
+ // persisted-agents → vibe/runtime → task/executor → sdk) close an import
95
+ // cycle back here, so reading it at init order-dependently hits its
96
+ // temporal dead zone. Deferring the read to first access sidesteps it.
97
+ get hub(): ToolRenderer {
98
+ return hubToolRenderer as ToolRenderer;
99
+ },
94
100
  read: readToolRenderer as ToolRenderer,
95
101
  // Keyed by xd:// resolution-device names: the write dispatch delegates here
96
102
  // by dispatch tool, and historical `resolve` tool transcripts still render
package/src/tools/todo.ts CHANGED
@@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
2
2
  import type { ToolExample } from "@oh-my-pi/pi-ai";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Text } from "@oh-my-pi/pi-tui";
5
- import { prompt } from "@oh-my-pi/pi-utils";
5
+ import { isRecord, prompt } from "@oh-my-pi/pi-utils";
6
6
  import { type } from "arktype";
7
7
  import chalk from "chalk";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -18,13 +18,15 @@ import { formatErrorDetail, formatMoreItems, PREVIEW_LIMITS, pluralize } from ".
18
18
  // Types
19
19
  // =============================================================================
20
20
 
21
- export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
21
+ export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned" | "blocked";
22
22
  /** Operation names accepted by the todo tool and echoed in successful result details. */
23
- export type TodoOperation = "init" | "start" | "done" | "rm" | "drop" | "append" | "view";
23
+ export type TodoOperation = "init" | "start" | "done" | "rm" | "drop" | "block" | "unblock" | "append" | "view";
24
24
 
25
25
  export interface TodoItem {
26
26
  content: string;
27
27
  status: TodoStatus;
28
+ /** When `status === "blocked"`, an optional note on what the task is waiting for. */
29
+ blocker?: string;
28
30
  }
29
31
 
30
32
  export interface TodoPhase {
@@ -32,6 +34,21 @@ export interface TodoPhase {
32
34
  tasks: TodoItem[];
33
35
  }
34
36
 
37
+ /** Whether an unknown value is a persisted todo phase. */
38
+ export function isTodoPhase(value: unknown): value is TodoPhase {
39
+ if (!isRecord(value) || typeof value.name !== "string" || !Array.isArray(value.tasks)) return false;
40
+ return value.tasks.every(
41
+ task =>
42
+ isRecord(task) &&
43
+ typeof task.content === "string" &&
44
+ (task.status === "pending" ||
45
+ task.status === "in_progress" ||
46
+ task.status === "completed" ||
47
+ task.status === "abandoned" ||
48
+ task.status === "blocked"),
49
+ );
50
+ }
51
+
35
52
  export interface TodoCompletionTransition {
36
53
  phase: string;
37
54
  content: string;
@@ -49,7 +66,9 @@ export interface TodoToolDetails {
49
66
  // Schema
50
67
  // =============================================================================
51
68
 
52
- const TodoOp = type('"init" | "start" | "done" | "rm" | "drop" | "append" | "view"').describe("operation to apply");
69
+ const TodoOp = type('"init" | "start" | "done" | "rm" | "drop" | "block" | "unblock" | "append" | "view"').describe(
70
+ "operation to apply",
71
+ );
53
72
 
54
73
  const InitListEntry = type({
55
74
  phase: type("string").describe("phase name"),
@@ -65,6 +84,7 @@ const todoSchema = type({
65
84
  // and both enforce non-empty with op-specific errors. A stray `items: []` on
66
85
  // an op that ignores it (e.g. `view`) must not be a hard schema rejection.
67
86
  "items?": type("string").describe("task content").array().describe("tasks to append"),
87
+ "reason?": type("string").describe("blocker note (block op)"),
68
88
  }).describe("apply a single todo operation");
69
89
 
70
90
  type TodoParams = TodoSchema;
@@ -89,7 +109,9 @@ function findPhaseByName(phases: TodoPhase[], name: string): TodoPhase | undefin
89
109
  }
90
110
 
91
111
  function cloneTask(task: TodoItem): TodoItem {
92
- return { content: task.content, status: task.status };
112
+ return task.blocker !== undefined
113
+ ? { content: task.content, status: task.status, blocker: task.blocker }
114
+ : { content: task.content, status: task.status };
93
115
  }
94
116
 
95
117
  function clonePhases(phases: TodoPhase[]): TodoPhase[] {
@@ -256,7 +278,9 @@ export function selectCollapsedTodos<T extends { status: TodoStatus }>(
256
278
  isMatched: (task: T) => boolean,
257
279
  cap: number,
258
280
  ): CollapsedTodoSelection<T> {
259
- const open = tasks.filter(task => task.status === "pending" || task.status === "in_progress");
281
+ const open = tasks.filter(
282
+ task => task.status === "pending" || task.status === "in_progress" || task.status === "blocked",
283
+ );
260
284
  // No open work: fall back to the closed tasks so a settled phase still
261
285
  // renders (HUD closed-todo persistence). Closed tasks are never active.
262
286
  const base = open.length > 0 ? open : tasks;
@@ -456,6 +480,41 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string
456
480
  }
457
481
  return phases;
458
482
  }
483
+ case "block": {
484
+ if (!entry.task && !entry.phase) {
485
+ errors.push("block requires a task or phase target");
486
+ return phases;
487
+ }
488
+ // Collapse whitespace runs (incl. newlines) to single spaces: a blocker
489
+ // note rides on one Markdown checklist line (as a trailing HTML comment)
490
+ // and one HUD/summary line, so an embedded newline from a multi-line
491
+ // external error or user question would corrupt the round-trip parse and
492
+ // the rendered line. Normalizing here keeps every consumer one-line-safe.
493
+ const reason = entry.reason?.replace(/\s+/g, " ").trim() || undefined;
494
+ for (const task of getTaskTargets(phases, entry, errors)) {
495
+ // Only actionable open work can be blocked: blocking a phase must not
496
+ // reopen completed/abandoned tasks or erase finished progress. An
497
+ // already-blocked task stays eligible so a later block can refine its
498
+ // blocker note (e.g. first blocked without a reason, then with one).
499
+ if (task.status !== "pending" && task.status !== "in_progress" && task.status !== "blocked") continue;
500
+ task.status = "blocked";
501
+ task.blocker = reason;
502
+ }
503
+ return phases;
504
+ }
505
+ case "unblock": {
506
+ if (!entry.task && !entry.phase) {
507
+ errors.push("unblock requires a task or phase target");
508
+ return phases;
509
+ }
510
+ for (const task of getTaskTargets(phases, entry, errors)) {
511
+ if (task.status === "blocked") {
512
+ task.status = "pending";
513
+ task.blocker = undefined;
514
+ }
515
+ }
516
+ return phases;
517
+ }
459
518
  case "rm":
460
519
  return removeTasks(phases, entry, errors);
461
520
  case "append":
@@ -495,6 +554,7 @@ const STATUS_TO_MARKER: Record<TodoStatus, string> = {
495
554
  in_progress: "/",
496
555
  completed: "x",
497
556
  abandoned: "-",
557
+ blocked: "!",
498
558
  };
499
559
 
500
560
  export function resolveTodoMarkdownPath(input: string, cwd: string): string {
@@ -510,7 +570,12 @@ export function phasesToMarkdown(phases: TodoPhase[]): string {
510
570
  if (i > 0) out.push("");
511
571
  out.push(`# ${phases[i].name}`);
512
572
  for (const task of phases[i].tasks) {
513
- out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
573
+ // A blocked task's reason rides in a trailing HTML comment: invisible in
574
+ // rendered markdown, unambiguous to parse back (task content can't
575
+ // contain the comment delimiters), so the note survives `/todo edit` and
576
+ // export/import round-trips.
577
+ const blockerNote = task.status === "blocked" && task.blocker ? ` <!-- blocker: ${task.blocker} -->` : "";
578
+ out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}${blockerNote}`);
514
579
  }
515
580
  }
516
581
  return `${out.join("\n")}\n`;
@@ -525,6 +590,7 @@ const MARKER_TO_STATUS: Record<string, TodoStatus> = {
525
590
  ">": "in_progress",
526
591
  "-": "abandoned",
527
592
  "~": "abandoned",
593
+ "!": "blocked",
528
594
  };
529
595
 
530
596
  /** Parse a Markdown checklist back into todo phases. */
@@ -556,10 +622,18 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
556
622
  const marker = taskMatch[1];
557
623
  const status = MARKER_TO_STATUS[marker];
558
624
  if (!status) {
559
- errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
625
+ errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-], [!])`);
560
626
  continue;
561
627
  }
562
- currentPhase.tasks.push({ content: taskMatch[2].trim(), status });
628
+ // Recover a blocked task's reason from its trailing HTML comment (see
629
+ // phasesToMarkdown), then strip the comment from the visible content.
630
+ const rawContent = taskMatch[2].trim();
631
+ const blockerMatch = /^(.*?)\s*<!--\s*blocker:\s*(.*?)\s*-->$/.exec(rawContent);
632
+ if (status === "blocked" && blockerMatch) {
633
+ currentPhase.tasks.push({ content: blockerMatch[1].trim(), status, blocker: blockerMatch[2].trim() });
634
+ } else {
635
+ currentPhase.tasks.push({ content: rawContent, status });
636
+ }
563
637
  continue;
564
638
  }
565
639
 
@@ -604,6 +678,7 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
604
678
  }
605
679
  // Closed = completed + abandoned, mirroring the per-phase `done` count.
606
680
  const closedAll = tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
681
+ const blockedAll = tasks.filter(task => task.status === "blocked").length;
607
682
  // The active phase is the EARLIEST one still holding open work, so the
608
683
  // in-progress pointer can sit in a phase whose successors already have
609
684
  // completed tasks. Detect that "worked ahead" case to explain the
@@ -613,7 +688,9 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
613
688
  (phase, idx) =>
614
689
  idx > currentIdx && phase.tasks.some(task => task.status === "completed" || task.status === "abandoned"),
615
690
  );
616
- lines.push(`Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open.`);
691
+ lines.push(
692
+ `Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open${blockedAll > 0 ? `, ${blockedAll} blocked` : ""}.`,
693
+ );
617
694
  lines.push(
618
695
  `Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${
619
696
  workedAhead
@@ -625,7 +702,16 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
625
702
  lines.push(` ${phase.name}:`);
626
703
  for (const task of phase.tasks) {
627
704
  const checkbox = task.status === "completed" ? "[X]" : "[ ]";
628
- const tag = task.status === "in_progress" ? " (in progress)" : task.status === "abandoned" ? " (dropped)" : "";
705
+ const tag =
706
+ task.status === "in_progress"
707
+ ? " (in progress)"
708
+ : task.status === "abandoned"
709
+ ? " (dropped)"
710
+ : task.status === "blocked"
711
+ ? task.blocker
712
+ ? ` (blocked: ${task.blocker})`
713
+ : " (blocked)"
714
+ : "";
629
715
  lines.push(` - ${checkbox} ${task.content}${tag}`);
630
716
  }
631
717
  }
@@ -845,6 +931,10 @@ function formatTodoLine(
845
931
  return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
846
932
  case "abandoned":
847
933
  return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`);
934
+ case "blocked": {
935
+ const note = item.blocker ? `blocked: ${item.blocker}` : "blocked";
936
+ return uiTheme.fg("warning", `${prefix}${checkbox.unchecked} ${item.content} (${note})`);
937
+ }
848
938
  default:
849
939
  // A pending todo lit by a live subagent match renders accent, matching
850
940
  // the sticky HUD's convention (#5873).
package/src/tools/vibe.ts CHANGED
@@ -23,7 +23,6 @@ import vibeListDescription from "../prompts/tools/vibe-list.md" with { type: "te
23
23
  import vibeSendDescription from "../prompts/tools/vibe-send.md" with { type: "text" };
24
24
  import vibeSpawnDescription from "../prompts/tools/vibe-spawn.md" with { type: "text" };
25
25
  import vibeWaitDescription from "../prompts/tools/vibe-wait.md" with { type: "text" };
26
- import { MAIN_AGENT_ID } from "../registry/agent-registry";
27
26
  import { oneLineLabel } from "../task/types";
28
27
  import { renderStatusLine } from "../tui";
29
28
  import {
@@ -93,7 +92,7 @@ export interface VibeToolDetails {
93
92
  }
94
93
 
95
94
  function screensOf(session: ToolSession, ids?: string[]): VibeScreenSnapshot[] {
96
- return VibeSessionRegistry.global().screens(session.getAgentId?.() ?? MAIN_AGENT_ID, ids);
95
+ return VibeSessionRegistry.global().screens(session, ids);
97
96
  }
98
97
 
99
98
  function textResult(text: string, details: VibeToolDetails): AgentToolResult<VibeToolDetails> {