@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/task/index.ts CHANGED
@@ -187,7 +187,6 @@ function renderDescription(
187
187
  agents: renderedAgents,
188
188
  spawningDisabled,
189
189
  defaultAgent: spawnPolicy.defaultAgent,
190
- allowedAgentsText: spawnPolicy.allowedPromptText,
191
190
  isolationEnabled,
192
191
  applyIsolatedChanges,
193
192
  batchEnabled,
@@ -531,19 +530,35 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
531
530
  if (typeof params.context === "string" && params.context.trim()) {
532
531
  lines.push(`Context:\n${truncateForPrompt(params.context)}`);
533
532
  }
534
- const tasks = Array.isArray(params.tasks) ? params.tasks : [];
535
- const firstTask = tasks[0];
536
- if (firstTask) {
537
- if (typeof firstTask.name === "string" && firstTask.name.trim()) {
538
- lines.push(`Name: ${truncateForPrompt(firstTask.name)}`);
539
- }
540
- if (typeof firstTask.agent === "string" && firstTask.agent.trim()) {
541
- lines.push(`Agent: ${truncateForPrompt(firstTask.agent)}`);
533
+ const tasks: unknown[] = Array.isArray(params.tasks) ? params.tasks : [];
534
+ if (tasks.length > 0) {
535
+ const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
536
+ const effectiveAgent = (item: unknown): string => {
537
+ if (item && typeof item === "object" && "agent" in item) {
538
+ const agent = item.agent;
539
+ if (typeof agent === "string" && agent.trim()) return agent.trim();
540
+ }
541
+ return defaultAgent;
542
+ };
543
+ const agentCounts = new Map<string, number>();
544
+ for (const item of tasks) {
545
+ const agent = effectiveAgent(item);
546
+ agentCounts.set(agent, (agentCounts.get(agent) ?? 0) + 1);
542
547
  }
543
- const itemModel = formatModelForApproval(firstTask.model);
544
- if (itemModel) lines.push(`Model: ${itemModel}`);
545
- if (typeof firstTask.task === "string") {
546
- lines.push(`Task:\n${truncateForPrompt(firstTask.task)}`);
548
+ const agentSummary = [...agentCounts].map(([agent, count]) => `${agent} ×${count}`).join(", ");
549
+ lines.push(`Batch agents: ${truncateForPrompt(agentSummary)}`);
550
+
551
+ const firstTask = tasks[0];
552
+ if (firstTask && typeof firstTask === "object") {
553
+ if ("name" in firstTask && typeof firstTask.name === "string" && firstTask.name.trim()) {
554
+ lines.push(`Name: ${truncateForPrompt(firstTask.name)}`);
555
+ }
556
+ lines.push(`Agent: ${truncateForPrompt(effectiveAgent(firstTask))}`);
557
+ const itemModel = formatModelForApproval("model" in firstTask ? firstTask.model : undefined);
558
+ if (itemModel) lines.push(`Model: ${itemModel}`);
559
+ if ("task" in firstTask && typeof firstTask.task === "string") {
560
+ lines.push(`Task:\n${truncateForPrompt(firstTask.task)}`);
561
+ }
547
562
  }
548
563
  if (tasks.length > 1) {
549
564
  lines.push(`+${tasks.length - 1} more task${tasks.length === 2 ? "" : "s"}`);
@@ -687,27 +702,53 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
687
702
  const spawnItems = resolveSpawnItems(params);
688
703
  const normalizedSpawnParams = spawnItems.map(item => spawnParamsFor(params, item, defaultAgent));
689
704
  const resolvedAgents = normalizedSpawnParams.map(spawn => spawn.agent ?? defaultAgent);
705
+ // Resolve every item before choosing an execution path. No executor or
706
+ // job manager may observe a batch unless every effective policy is valid.
707
+ const preflights = await Promise.all(
708
+ normalizedSpawnParams.map(async spawn => {
709
+ try {
710
+ return { policy: await this.#resolveSpawnPreflight(spawn) };
711
+ } catch (error) {
712
+ return { error: error instanceof StructuredSubagentError ? error.message : String(error) };
713
+ }
714
+ }),
715
+ );
716
+ const preflightFailures = preflights
717
+ .map((preflight, index) => ("error" in preflight ? { index, error: preflight.error } : undefined))
718
+ .filter((failure): failure is { index: number; error: string } => failure !== undefined);
719
+ if (preflightFailures.length > 0) {
720
+ if (!batchEnabled) {
721
+ return createTaskModeError(`Task execution failed: ${preflightFailures[0]!.error}`);
722
+ }
723
+ return createTaskModeError(
724
+ preflightFailures
725
+ .map(({ index, error }) => {
726
+ const item = spawnItems[index]!;
727
+ return `Task ${item.name?.trim() || `#${index + 1}`} failed preflight: ${error}`;
728
+ })
729
+ .join("\n"),
730
+ );
731
+ }
732
+ const policies = preflights.map(preflight => preflight.policy!);
733
+ const itemBlocking = policies.map(policy => policy.effectiveAgent.blocking === true);
734
+
690
735
  // Execution mode is per item: an item whose agent type declares
691
736
  // `blocking: true` runs inline on this turn (the parent waits on its
692
737
  // result); every other item becomes a background job when async
693
738
  // execution is available.
694
- const provisionalBlocking = resolvedAgents.map(
695
- name => this.#discoveredAgents.find(agent => agent.name === name)?.blocking === true,
696
- );
697
739
  const asyncEnabled = this.session.settings.get("async.enabled");
698
740
  const manager = asyncEnabled ? this.session.asyncJobManager : undefined;
699
- const provisionalAsyncItems = manager ? spawnItems.filter((_, index) => !provisionalBlocking[index]) : [];
741
+ const asyncItems = manager ? spawnItems.filter((_, index) => !itemBlocking[index]) : [];
700
742
  const depthCapacity = canSpawnAtDepth(
701
743
  this.session.settings.get("task.maxRecursionDepth") ?? 2,
702
744
  this.session.taskDepth ?? 0,
703
745
  );
704
746
  const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
705
747
 
706
- if (!manager || provisionalAsyncItems.length === 0) {
748
+ if (!manager || asyncItems.length === 0) {
707
749
  // Sync fallback: async execution disabled, orphaned host that never
708
750
  // wired a job manager, or every item's agent type declares
709
- // `blocking: true`. `runStructuredSubagent` performs its own shared
710
- // preflight before reserving an id in these inline paths.
751
+ // `blocking: true`.
711
752
  if (asyncEnabled && !this.session.asyncJobManager) {
712
753
  logger.warn("task: no AsyncJobManager registered; falling back to sync execution");
713
754
  }
@@ -715,7 +756,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
715
756
  ? undefined
716
757
  : composeSpawnAdvisory({
717
758
  agents: resolvedAgents,
718
- items: provisionalAsyncItems,
759
+ items: asyncItems,
719
760
  depthCapacity,
720
761
  ircEnabled,
721
762
  willRunAsync: false,
@@ -741,43 +782,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
741
782
  return { ...result, content };
742
783
  }
743
784
 
744
- // Async jobs are otherwise registered before their body can reach
745
- // `runStructuredSubagent`. Resolve the shared policy first so policy
746
- // failures remain synchronous and cannot leave a queued invalid job.
747
- const preflights = await Promise.all(
748
- normalizedSpawnParams.map(async spawn => {
749
- try {
750
- return { policy: await this.#resolveSpawnPreflight(spawn) };
751
- } catch (error) {
752
- return { error: error instanceof StructuredSubagentError ? error.message : String(error) };
753
- }
754
- }),
755
- );
756
- const preflightFailures = preflights
757
- .map((preflight, index) => ("error" in preflight ? { index, error: preflight.error } : undefined))
758
- .filter((failure): failure is { index: number; error: string } => failure !== undefined);
759
- const renderPreflightFailures = () =>
760
- preflightFailures
761
- .map(({ index, error }) => {
762
- const item = spawnItems[index]!;
763
- return `Task ${item.name?.trim() || `#${index + 1}`} failed preflight: ${error}`;
764
- })
765
- .join("\n");
766
- if (preflightFailures.length === spawnItems.length) {
767
- return createTaskModeError(renderPreflightFailures());
768
- }
769
-
770
- const validIndices = preflights.flatMap((preflight, index) => (preflight.policy ? [index] : []));
771
- const validSpawns = validIndices.map(index => ({ item: spawnItems[index]!, index }));
772
- const itemBlocking = preflights.map(preflight => preflight.policy?.effectiveAgent.blocking === true);
773
- const asyncItems = validIndices.filter(index => !itemBlocking[index]).map(index => spawnItems[index]!);
774
785
  // Coordination only makes sense for spawns that keep running after this
775
786
  // call returns (the async subset). Blocking items have already completed
776
787
  // by then, so a "coordinate while they run" hint would misfire.
777
788
  const advisory = this.session.suppressSpawnAdvisory
778
789
  ? undefined
779
790
  : composeSpawnAdvisory({
780
- agents: validIndices.map(index => resolvedAgents[index]!),
791
+ agents: resolvedAgents,
781
792
  items: asyncItems,
782
793
  depthCapacity,
783
794
  ircEnabled,
@@ -799,24 +810,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
799
810
  if (!appended) content.push({ type: "text", text: advisory });
800
811
  return { ...result, content };
801
812
  };
802
- const withPreflightFailures = (result: AgentToolResult<TaskToolDetails>): AgentToolResult<TaskToolDetails> => {
803
- if (preflightFailures.length === 0) return result;
804
- const failures = renderPreflightFailures();
805
- let prepended = false;
806
- const content = result.content.map(part => {
807
- if (!prepended && part.type === "text" && typeof part.text === "string") {
808
- prepended = true;
809
- return { ...part, text: `${failures}\n\n${part.text}` };
810
- }
811
- return part;
812
- });
813
- if (!prepended) content.unshift({ type: "text", text: failures });
814
- return { ...result, content };
815
- };
816
813
  if (asyncItems.length === 0) {
817
- return withPreflightFailures(
818
- withAdvisory(
819
- await this.#executeSyncFanout(toolCallId, params, validSpawns, defaultAgent, signal, onUpdate),
814
+ return withAdvisory(
815
+ await this.#executeSyncFanout(
816
+ toolCallId,
817
+ params,
818
+ spawnItems.map((item, index) => ({ item, index })),
819
+ defaultAgent,
820
+ signal,
821
+ onUpdate,
820
822
  ),
821
823
  );
822
824
  }
@@ -836,12 +838,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
836
838
  blocking: boolean;
837
839
  progress: AgentProgress;
838
840
  }> = [];
839
- for (const index of validIndices) {
840
- const item = spawnItems[index]!;
841
+ for (const [index, item] of spawnItems.entries()) {
841
842
  const agentType = resolvedAgents[index]!;
842
- const preflight = preflights[index]!;
843
- const policy = preflight.policy;
844
- if (!policy) continue;
843
+ const policy = policies[index]!;
845
844
  const agentSource = policy.agent.source;
846
845
  const agentId = await outputManager.allocate(item.name?.trim() || generateTaskName());
847
846
  const assignment = (item.task ?? "").trim();
@@ -878,7 +877,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
878
877
  // failed. Blocking spawns run inline below and land in `results` before
879
878
  // the call returns, so post-return job updates never drop them.
880
879
  let settledCount = 0;
881
- let failedCount = preflightFailures.length;
880
+ let failedCount = 0;
882
881
  let primaryJobId = asyncSpawns[0].agentId;
883
882
  const syncResults: SingleResult[] = [];
884
883
  let syncUsage: Usage | undefined;
@@ -928,7 +927,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
928
927
  }
929
928
 
930
929
  if (started.length === 0 && syncSpawns.length === 0) {
931
- return withPreflightFailures({
930
+ return {
932
931
  content: [
933
932
  {
934
933
  type: "text",
@@ -936,7 +935,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
936
935
  },
937
936
  ],
938
937
  details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
939
- });
938
+ };
940
939
  }
941
940
 
942
941
  const scheduleFailureSummary =
@@ -961,34 +960,30 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
961
960
  content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }],
962
961
  details: buildAsyncDetails(),
963
962
  });
964
- return withPreflightFailures(
965
- withAdvisory({
966
- content: [
967
- {
968
- type: "text",
969
- text: `Spawned agent \`${agentId}\` (job \`${jobId}\`). Its result auto-delivers on yield unless a settled \`hub jobs\`/\`wait\` snapshot consumes it first. ${coordinationHint}`,
970
- },
971
- ],
972
- details: buildAsyncDetails(),
973
- }),
974
- );
975
- }
976
- const startedListing = started.map(({ agentId, jobId }) => `- \`${agentId}\` (job \`${jobId}\`)`).join("\n");
977
- onUpdate?.({
978
- content: [{ type: "text", text: `Spawned ${started.length} agents...` }],
979
- details: buildAsyncDetails(),
980
- });
981
- return withPreflightFailures(
982
- withAdvisory({
963
+ return withAdvisory({
983
964
  content: [
984
965
  {
985
966
  type: "text",
986
- text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result auto-delivers on yield unless a settled \`hub jobs\`/\`wait\` snapshot consumes it first.\n${startedListing}\n${coordinationHint}`,
967
+ text: `Spawned agent \`${agentId}\` (job \`${jobId}\`). Its result auto-delivers on yield unless a settled \`hub jobs\`/\`wait\` snapshot consumes it first. ${coordinationHint}`,
987
968
  },
988
969
  ],
989
970
  details: buildAsyncDetails(),
990
- }),
991
- );
971
+ });
972
+ }
973
+ const startedListing = started.map(({ agentId, jobId }) => `- \`${agentId}\` (job \`${jobId}\`)`).join("\n");
974
+ onUpdate?.({
975
+ content: [{ type: "text", text: `Spawned ${started.length} agents...` }],
976
+ details: buildAsyncDetails(),
977
+ });
978
+ return withAdvisory({
979
+ content: [
980
+ {
981
+ type: "text",
982
+ text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result auto-delivers on yield unless a settled \`hub jobs\`/\`wait\` snapshot consumes it first.\n${startedListing}\n${coordinationHint}`,
983
+ },
984
+ ],
985
+ details: buildAsyncDetails(),
986
+ });
992
987
  }
993
988
 
994
989
  // Mixed call: the async jobs above already run detached; the blocking
@@ -1053,12 +1048,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1053
1048
  const text = [merged.contentParts.join("\n\n"), spawnedSummary]
1054
1049
  .filter(section => section.trim().length > 0)
1055
1050
  .join("\n\n");
1056
- return withPreflightFailures(
1057
- withAdvisory({
1058
- content: [{ type: "text", text: text.length > 0 ? text : "No results." }],
1059
- details: buildAsyncDetails(),
1060
- }),
1061
- );
1051
+ return withAdvisory({
1052
+ content: [{ type: "text", text: text.length > 0 ? text : "No results." }],
1053
+ details: buildAsyncDetails(),
1054
+ });
1062
1055
  }
1063
1056
 
1064
1057
  /**
@@ -18,10 +18,11 @@ import { ADVISOR_TRANSCRIPT_STEM } from "../advisor/transcript-recorder";
18
18
  *
19
19
  * The first allocation of a given name keeps the name as-is; subsequent
20
20
  * allocations of the same name get a `-2`, `-3`, … suffix. On resume, scans
21
- * existing output files so previously written outputs are never overwritten.
21
+ * existing output and child-session files so prior state is never overwritten.
22
22
  */
23
23
  export class AgentOutputManager {
24
24
  #initialized = false;
25
+ #initializing: Promise<void> | undefined;
25
26
  /** Final ids already handed out, relative to this manager's scope. */
26
27
  readonly #taken = new Set<string>();
27
28
  readonly #getArtifactsDir: () => string | null;
@@ -42,8 +43,12 @@ export class AgentOutputManager {
42
43
  */
43
44
  async #ensureInitialized(): Promise<void> {
44
45
  if (this.#initialized) return;
46
+ this.#initializing ??= this.#seedFromDisk();
47
+ await this.#initializing;
45
48
  this.#initialized = true;
49
+ }
46
50
 
51
+ async #seedFromDisk(): Promise<void> {
47
52
  const dir = this.#getArtifactsDir();
48
53
  if (!dir) return;
49
54
 
@@ -56,8 +61,9 @@ export class AgentOutputManager {
56
61
 
57
62
  const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
58
63
  for (const file of files) {
59
- if (!file.endsWith(".md")) continue;
60
- let rest = file.slice(0, -3); // drop ".md"
64
+ const extension = file.endsWith(".jsonl") ? ".jsonl" : file.endsWith(".md") ? ".md" : undefined;
65
+ if (!extension) continue;
66
+ let rest = file.slice(0, -extension.length);
61
67
  if (prefix) {
62
68
  if (!rest.startsWith(prefix)) continue;
63
69
  rest = rest.slice(prefix.length);
@@ -80,6 +86,22 @@ export class AgentOutputManager {
80
86
  return this.#parentPrefix ? `${this.#parentPrefix}.${candidate}` : candidate;
81
87
  }
82
88
 
89
+ /** Reserve final IDs discovered outside the output directory scan. */
90
+ async reserve(ids: Iterable<string>): Promise<void> {
91
+ await this.#ensureInitialized();
92
+ const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
93
+ for (const id of ids) {
94
+ let rest = id;
95
+ if (prefix) {
96
+ if (!rest.startsWith(prefix)) continue;
97
+ rest = rest.slice(prefix.length);
98
+ }
99
+ const dot = rest.indexOf(".");
100
+ const segment = dot === -1 ? rest : rest.slice(0, dot);
101
+ if (segment) this.#taken.add(segment);
102
+ }
103
+ }
104
+
83
105
  /**
84
106
  * Allocate a unique ID.
85
107
  *
@@ -71,7 +71,7 @@ export function createPersistedSubagentReviverFactory(
71
71
  taskDepth++;
72
72
  parentId = registry.get(parentId)?.parentId;
73
73
  }
74
- return async () => {
74
+ return async expectedRef => {
75
75
  // Re-open fresh on every revive: park closes the writer, so this takes
76
76
  // the single-writer lock cleanly and restores the full message history.
77
77
  const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
@@ -97,6 +97,7 @@ export function createPersistedSubagentReviverFactory(
97
97
  agentDisplayName: ref.displayName,
98
98
  parentTaskPrefix: ref.id,
99
99
  parentAgentId: ref.parentId,
100
+ expectedAgentRef: expectedRef,
100
101
  taskDepth,
101
102
  toolNames: init.tools,
102
103
  outputSchema: init.outputSchema,
@@ -131,8 +132,8 @@ export function createPersistedSubagentReviverFactory(
131
132
  // Without it the idle-TTL timer never clears on a turn and the lifecycle
132
133
  // could park the agent mid-run.
133
134
  session.subscribe(event => {
134
- if (event.type === "agent_start") registry.setStatus(ref.id, "running");
135
- else if (event.type === "agent_end") registry.setStatus(ref.id, "idle");
135
+ if (event.type === "agent_start") registry.setStatus(ref.id, "running", session);
136
+ else if (event.type === "agent_end") registry.setStatus(ref.id, "idle", session);
136
137
  });
137
138
  return session;
138
139
  };
@@ -373,6 +373,7 @@ function buildExecutorOptions(
373
373
  const enableMCP = !policy.planMode && (session.enableMCP ?? true);
374
374
  return {
375
375
  cwd: session.cwd,
376
+ additionalDirectories: session.additionalDirectories,
376
377
  agent: policy.effectiveAgent,
377
378
  task: renderSubagentPrompt(request.assignment),
378
379
  assignment: request.assignment.trim(),
@@ -20,6 +20,7 @@ export interface ResolvedApproval {
20
20
  tier: ToolTier;
21
21
  reason?: string;
22
22
  override: boolean;
23
+ source?: "tool" | "user" | "mode";
23
24
  }
24
25
 
25
26
  const POLICY_VALUES: ReadonlySet<ApprovalPolicy> = new Set(["allow", "deny", "prompt"]);
@@ -50,7 +51,7 @@ function isToolTier(value: unknown): value is ToolTier {
50
51
  return typeof value === "string" && TIER_VALUES.has(value as ToolTier);
51
52
  }
52
53
 
53
- function normalizeDecision(value: unknown): Omit<ResolvedApproval, "policy"> {
54
+ function normalizeDecision(value: unknown): Omit<ResolvedApproval, "policy"> & { policy?: ApprovalPolicy } {
54
55
  if (isToolTier(value)) {
55
56
  return { tier: value, override: false };
56
57
  }
@@ -59,9 +60,11 @@ function normalizeDecision(value: unknown): Omit<ResolvedApproval, "policy"> {
59
60
  const record = value as Record<string, unknown>;
60
61
  const tier = isToolTier(record.tier) ? record.tier : "exec";
61
62
  const reason = typeof record.reason === "string" && record.reason.length > 0 ? record.reason : undefined;
63
+ const policy = normalizePolicy(record.policy);
62
64
  return {
63
65
  tier,
64
66
  override: record.override === true,
67
+ ...(policy ? { policy } : {}),
65
68
  ...(reason ? { reason } : {}),
66
69
  };
67
70
  }
@@ -69,7 +72,10 @@ function normalizeDecision(value: unknown): Omit<ResolvedApproval, "policy"> {
69
72
  return { tier: "exec", override: false };
70
73
  }
71
74
 
72
- function getToolDecision(tool: ApprovalSubject, args: unknown): Omit<ResolvedApproval, "policy"> {
75
+ function getToolDecision(
76
+ tool: ApprovalSubject,
77
+ args: unknown,
78
+ ): Omit<ResolvedApproval, "policy"> & { policy?: ApprovalPolicy } {
73
79
  const approval = tool.approval;
74
80
  const decision: ToolApprovalDecision | undefined = typeof approval === "function" ? approval(args) : approval;
75
81
  return normalizeDecision(decision);
@@ -110,34 +116,70 @@ export function resolveApproval(
110
116
  const decision = getToolDecision(tool, args);
111
117
  const userPolicy = Object.hasOwn(userConfig, tool.name) ? normalizePolicy(userConfig[tool.name]) : undefined;
112
118
 
119
+ if (decision.policy === "deny") {
120
+ return {
121
+ policy: "deny",
122
+ tier: decision.tier,
123
+ override: decision.override,
124
+ source: "tool",
125
+ ...(decision.reason ? { reason: decision.reason } : {}),
126
+ };
127
+ }
128
+ if (userPolicy === "deny") {
129
+ return { policy: "deny", tier: decision.tier, override: decision.override, source: "user" };
130
+ }
131
+
113
132
  if (mode === "yolo") {
114
- return { policy: userPolicy ?? "allow", tier: decision.tier, override: false };
133
+ if (decision.policy) {
134
+ return {
135
+ policy: decision.policy,
136
+ tier: decision.tier,
137
+ override: false,
138
+ source: "tool",
139
+ ...(decision.reason ? { reason: decision.reason } : {}),
140
+ };
141
+ }
142
+ return {
143
+ policy: userPolicy ?? "allow",
144
+ tier: decision.tier,
145
+ override: false,
146
+ source: userPolicy ? "user" : "mode",
147
+ };
115
148
  }
116
149
 
117
150
  if (decision.override) {
118
- if (userPolicy === "deny") {
119
- return { policy: "deny", tier: decision.tier, override: true };
120
- }
121
151
  return {
122
- policy: "prompt",
152
+ policy: decision.policy === "allow" ? "allow" : "prompt",
123
153
  tier: decision.tier,
124
154
  override: true,
155
+ source: "tool",
156
+ ...(decision.reason ? { reason: decision.reason } : {}),
157
+ };
158
+ }
159
+
160
+ if (decision.policy === "allow" || decision.policy === "prompt") {
161
+ return {
162
+ policy: decision.policy,
163
+ tier: decision.tier,
164
+ override: false,
165
+ source: "tool",
125
166
  ...(decision.reason ? { reason: decision.reason } : {}),
126
167
  };
127
168
  }
128
169
 
129
170
  if (userPolicy) {
130
- return { policy: userPolicy, tier: decision.tier, override: false };
171
+ return { policy: userPolicy, tier: decision.tier, override: false, source: "user" };
131
172
  }
132
173
 
133
174
  if (modeApprovesTier(mode, decision.tier)) {
134
- return { policy: "allow", tier: decision.tier, override: false };
175
+ return { policy: "allow", tier: decision.tier, override: false, source: "mode" };
135
176
  }
136
177
 
137
178
  return {
138
179
  policy: "prompt",
139
180
  tier: decision.tier,
140
181
  override: false,
182
+ source: "mode",
141
183
  ...(decision.reason ? { reason: decision.reason } : {}),
142
184
  };
143
185
  }
@@ -154,9 +196,12 @@ export function requiresApproval(
154
196
  mode: ApprovalMode,
155
197
  userConfig: Record<string, unknown> = {},
156
198
  ): { required: boolean; reason?: string } {
157
- const { policy, reason } = resolveApproval(tool, args, mode, userConfig);
199
+ const { policy, reason, source } = resolveApproval(tool, args, mode, userConfig);
158
200
 
159
201
  if (policy === "deny") {
202
+ if (source === "tool") {
203
+ throw new Error(`Tool "${tool.name}" is blocked by tool policy.${reason ? `\nReason: ${reason}` : ""}`);
204
+ }
160
205
  throw new Error(
161
206
  `Tool "${tool.name}" is blocked by user policy.\n` +
162
207
  `To allow: remove "tools.approval.${tool.name}: deny" from config.`,
@@ -32,6 +32,10 @@ function normalizeCaptureChunk(chunk: string): string {
32
32
  return sanitizeWithOptionalSixelPassthrough(normalized, sanitizeText);
33
33
  }
34
34
 
35
+ // Caps only the live xterm display backlog; OutputSink remains the bounded
36
+ // source of truth for the final captured output.
37
+ const MAX_LIVE_WRITE_QUEUE_CHUNKS = 512;
38
+
35
39
  // @xterm/headless is only needed once an interactive PTY session actually starts,
36
40
  // so it is loaded lazily (and memoized) instead of weighing down CLI startup.
37
41
  let xtermTerminalCtor: typeof XtermModule.Terminal | undefined;
@@ -142,9 +146,31 @@ class BashInteractiveOverlayComponent implements Component {
142
146
 
143
147
  appendOutput(chunk: string): void {
144
148
  this.#writeQueue.push(chunk);
149
+ this.#trimWriteQueue();
145
150
  this.#drainQueue();
146
151
  }
147
152
 
153
+ #trimWriteQueue(): void {
154
+ // Compact the consumed prefix first: the queue only self-resets on a
155
+ // full drain, which never happens while a fast producer keeps a
156
+ // backlog alive, so already-written chunks must be released here to
157
+ // keep the retained array itself bounded.
158
+ if (this.#writeOffset > 0) {
159
+ this.#writeQueue.splice(0, this.#writeOffset);
160
+ this.#writeOffset = 0;
161
+ }
162
+ const firstPending = this.#writing ? 1 : 0;
163
+ const overflow = this.#writeQueue.length - firstPending - MAX_LIVE_WRITE_QUEUE_CHUNKS;
164
+ if (overflow > 0) {
165
+ this.#writeQueue.splice(firstPending, overflow);
166
+ // Dropped chunks can split an in-flight DCS/OSC/APC string (e.g. a
167
+ // sixel payload) across the gap; a stray string terminator is a
168
+ // no-op in the ground state but resynchronizes the parser if the
169
+ // terminator was dropped.
170
+ this.#writeQueue[firstPending] = `\u001b\\${this.#writeQueue[firstPending]}`;
171
+ }
172
+ }
173
+
148
174
  #drainQueue(): void {
149
175
  if (this.#writing) return;
150
176
  if (this.#writeOffset >= this.#writeQueue.length) {
@@ -144,6 +144,8 @@ function unquoteToken(token: string): string {
144
144
  function isInsideShellQuote(command: string, index: number): boolean {
145
145
  type ShellQuote = "'" | '"' | undefined;
146
146
  interface CommandSubstitution {
147
+ /** `$(` … `)` tracks paren depth; `` ` `` … `` ` `` is a plain toggle. */
148
+ kind: "dollar" | "backtick";
147
149
  outerQuote: ShellQuote;
148
150
  depth: number;
149
151
  }
@@ -152,6 +154,19 @@ function isInsideShellQuote(command: string, index: number): boolean {
152
154
  const substitutions: CommandSubstitution[] = [];
153
155
  for (let i = 0; i < index; i++) {
154
156
  const char = command[i];
157
+ // Inside a backtick substitution nested in double quotes, bash treats `\"`
158
+ // as a quote delimiter for the inner command, not as an escaped literal.
159
+ if (
160
+ char === "\\" &&
161
+ command[i + 1] === '"' &&
162
+ quote !== "'" &&
163
+ substitutions.at(-1)?.kind === "backtick" &&
164
+ substitutions.at(-1)?.outerQuote === '"'
165
+ ) {
166
+ quote = quote === '"' ? undefined : '"';
167
+ i++;
168
+ continue;
169
+ }
155
170
  if (char === "\\" && quote !== "'") {
156
171
  i++;
157
172
  continue;
@@ -165,15 +180,26 @@ function isInsideShellQuote(command: string, index: number): boolean {
165
180
  continue;
166
181
  }
167
182
  if (char === "$" && command[i + 1] === "(" && quote !== "'") {
168
- substitutions.push({ outerQuote: quote, depth: 1 });
183
+ substitutions.push({ kind: "dollar", outerQuote: quote, depth: 1 });
169
184
  quote = undefined;
170
185
  i++;
171
186
  continue;
172
187
  }
188
+ if (char === "`" && quote !== "'") {
189
+ const top = substitutions.at(-1);
190
+ if (top?.kind === "backtick") {
191
+ substitutions.pop();
192
+ quote = top.outerQuote;
193
+ } else {
194
+ substitutions.push({ kind: "backtick", outerQuote: quote, depth: 0 });
195
+ quote = undefined;
196
+ }
197
+ continue;
198
+ }
173
199
  if (quote !== undefined) continue;
174
200
 
175
201
  const substitution = substitutions.at(-1);
176
- if (!substitution) continue;
202
+ if (substitution?.kind !== "dollar") continue;
177
203
  if (char === "(") {
178
204
  substitution.depth++;
179
205
  } else if (char === ")") {