one-code-extension 0.4.1 → 0.6.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 (439) hide show
  1. package/README.md +16 -5
  2. package/agents/general-purpose.md +2 -2
  3. package/docs/guide/README.md +2 -1
  4. package/docs/guide/bring-your-claude-code-setup.md +60 -9
  5. package/docs/guide/configuration.md +48 -14
  6. package/docs/guide/differences-from-claude-code.md +40 -28
  7. package/docs/guide/doctor.md +20 -13
  8. package/docs/guide/hooks.md +18 -3
  9. package/docs/guide/installation.md +26 -4
  10. package/docs/guide/permissions-modes-and-auto-mode.md +225 -49
  11. package/docs/guide/providers-and-models.md +137 -54
  12. package/docs/guide/reference.md +27 -15
  13. package/docs/guide/sessions-and-context.md +69 -16
  14. package/docs/guide/skills-plugins-and-mcp.md +57 -20
  15. package/docs/guide/subagents-and-workflows.md +95 -29
  16. package/docs/guide/tasks-and-background-work.md +101 -26
  17. package/docs/guide/terminal-interface.md +24 -12
  18. package/docs/guide/tools.md +46 -5
  19. package/docs/guide/troubleshooting.md +13 -9
  20. package/docs/guide/windows.md +58 -31
  21. package/extensions/artifacts/gallery.ts +227 -0
  22. package/extensions/artifacts/index.ts +288 -0
  23. package/extensions/artifacts/store.ts +570 -0
  24. package/extensions/artifacts/viewer.ts +199 -0
  25. package/extensions/ask-user/description.ts +67 -0
  26. package/extensions/ask-user/dialogs.ts +80 -0
  27. package/extensions/ask-user/index.ts +37 -33
  28. package/extensions/ask-user/questions.ts +32 -7
  29. package/extensions/ask-user/widget.ts +45 -25
  30. package/extensions/auto-mode/actions.ts +6 -0
  31. package/extensions/auto-mode/cache.ts +88 -0
  32. package/extensions/auto-mode/classifier-prompt.ts +5 -4
  33. package/extensions/auto-mode/classifier.ts +290 -65
  34. package/extensions/auto-mode/config.ts +72 -20
  35. package/extensions/auto-mode/decision-log.ts +2 -0
  36. package/extensions/auto-mode/defaults.ts +1 -1
  37. package/extensions/auto-mode/denials.ts +124 -0
  38. package/extensions/auto-mode/git-checkout-programs.ts +187 -0
  39. package/extensions/auto-mode/git-status-meta.ts +159 -0
  40. package/extensions/auto-mode/history.ts +189 -0
  41. package/extensions/auto-mode/instructions.ts +58 -52
  42. package/extensions/auto-mode/model-select.ts +159 -134
  43. package/extensions/auto-mode/paths.ts +57 -5
  44. package/extensions/auto-mode/pause.ts +57 -2
  45. package/extensions/auto-mode/prompt.ts +37 -21
  46. package/extensions/auto-mode/read-only-options.ts +724 -0
  47. package/extensions/auto-mode/resolved-paths-meta.ts +133 -0
  48. package/extensions/auto-mode/rules.ts +18 -1
  49. package/extensions/auto-mode/safety-floor.ts +361 -18
  50. package/extensions/auto-mode/setup-run.ts +20 -14
  51. package/extensions/auto-mode/shell-analysis.ts +764 -490
  52. package/extensions/auto-mode/shell-parse.ts +885 -0
  53. package/extensions/auto-mode/system-note.ts +33 -0
  54. package/extensions/auto-mode/transcript.ts +80 -51
  55. package/extensions/background/cron.ts +642 -0
  56. package/extensions/background/index.ts +614 -248
  57. package/extensions/background/loop-fire.ts +173 -0
  58. package/extensions/background/loop-preamble.ts +59 -0
  59. package/extensions/background/loop-skill.ts +163 -0
  60. package/extensions/background/monitor-batch.ts +53 -0
  61. package/extensions/background/noop-fold.ts +128 -0
  62. package/extensions/background/switch-guard.ts +56 -0
  63. package/extensions/background/wakeup.ts +178 -55
  64. package/extensions/bash/background.ts +53 -17
  65. package/extensions/bash/child.ts +7 -0
  66. package/extensions/bash/description.ts +188 -0
  67. package/extensions/bash/guards.ts +15 -67
  68. package/extensions/bash/index.ts +66 -35
  69. package/extensions/branding/compaction-keep.ts +63 -0
  70. package/extensions/branding/declined.ts +22 -0
  71. package/extensions/branding/index.ts +133 -9
  72. package/extensions/branding/prompt-editor.ts +53 -6
  73. package/extensions/branding/queued-edit.ts +31 -0
  74. package/extensions/branding/replaced-builtins.ts +68 -0
  75. package/extensions/branding/startup.ts +33 -71
  76. package/extensions/btw/index.ts +250 -78
  77. package/extensions/btw/panel.ts +228 -100
  78. package/extensions/btw/prompt.ts +41 -4
  79. package/extensions/cache-marks/index.ts +42 -0
  80. package/extensions/claude-compat/index.ts +7 -11
  81. package/extensions/claude-context/index.ts +436 -63
  82. package/extensions/compaction/cut.ts +157 -0
  83. package/extensions/compaction/index.ts +219 -75
  84. package/extensions/compaction/kept-thinking.ts +62 -0
  85. package/extensions/compaction/prompt.ts +15 -12
  86. package/extensions/context-budget/budget.ts +16 -2
  87. package/extensions/context-budget/index.ts +35 -15
  88. package/extensions/context-management/index.ts +36 -28
  89. package/extensions/doctor/build.ts +59 -8
  90. package/extensions/doctor/builtins.ts +84 -0
  91. package/extensions/doctor/cli.ts +7 -1
  92. package/extensions/doctor/compat.ts +88 -23
  93. package/extensions/doctor/dependencies.ts +41 -5
  94. package/extensions/doctor/fix-prompt.ts +9 -2
  95. package/extensions/doctor/index.ts +102 -58
  96. package/extensions/doctor/models.ts +102 -63
  97. package/extensions/doctor/presets.ts +42 -27
  98. package/extensions/doctor/report.ts +19 -2
  99. package/extensions/doctor/viewer.ts +49 -27
  100. package/extensions/effort/index.ts +141 -25
  101. package/extensions/effort/settings.ts +21 -0
  102. package/extensions/effort/slider.ts +24 -20
  103. package/extensions/exit/index.ts +32 -1
  104. package/extensions/file-tracker/index.ts +420 -42
  105. package/extensions/file-tracker/replay.ts +137 -24
  106. package/extensions/file-tracker/restore.ts +66 -0
  107. package/extensions/file-tracker/results.ts +66 -0
  108. package/extensions/file-tracker/shell-reads.ts +211 -0
  109. package/extensions/file-tracker/tracker.ts +45 -15
  110. package/extensions/footer/footer-line.ts +45 -24
  111. package/extensions/footer/index.ts +47 -2
  112. package/extensions/footer/pr.ts +34 -5
  113. package/extensions/hooks/compaction.ts +7 -0
  114. package/extensions/hooks/executor.ts +32 -30
  115. package/extensions/hooks/index.ts +209 -58
  116. package/extensions/hooks/matcher.ts +3 -0
  117. package/extensions/hooks/prompt-context.ts +59 -0
  118. package/extensions/hooks/protocol.ts +41 -3
  119. package/extensions/hooks/settings.ts +50 -19
  120. package/extensions/hooks/subagent-bridge.ts +2 -0
  121. package/extensions/hooks/trust.ts +26 -12
  122. package/extensions/idle-compact/index.ts +119 -0
  123. package/extensions/idle-compact/policy.ts +143 -0
  124. package/extensions/init/index.ts +11 -10
  125. package/extensions/init/prompt.ts +25 -32
  126. package/extensions/interrupted/index.ts +13 -1
  127. package/extensions/kept-thinking/index.ts +27 -0
  128. package/extensions/ledger/index.ts +46 -0
  129. package/extensions/ledger/wire.ts +154 -0
  130. package/extensions/lib/agent-cron.ts +146 -0
  131. package/extensions/lib/agent-loader.ts +18 -3
  132. package/extensions/lib/agent-view.ts +85 -0
  133. package/extensions/lib/anthropic-auth.ts +98 -0
  134. package/extensions/lib/anthropic-payload.ts +82 -1
  135. package/extensions/lib/anthropic-server-call.ts +230 -0
  136. package/extensions/lib/anthropic-server-tools.ts +485 -0
  137. package/extensions/lib/app-launch.mjs +94 -0
  138. package/extensions/lib/argument-hints.ts +190 -0
  139. package/extensions/lib/atomic-write.ts +6 -2
  140. package/extensions/lib/auto-memory.ts +75 -0
  141. package/extensions/lib/bash-parser.ts +84 -0
  142. package/extensions/lib/btw-fork.ts +93 -0
  143. package/extensions/lib/child-extensions.ts +52 -0
  144. package/extensions/lib/child-writes.ts +45 -0
  145. package/extensions/lib/claude-context.ts +515 -100
  146. package/extensions/lib/claude-external-includes.ts +68 -0
  147. package/extensions/lib/claude-id.ts +22 -0
  148. package/extensions/lib/claude-rules.ts +323 -0
  149. package/extensions/lib/claude-settings.ts +76 -4
  150. package/extensions/lib/compaction-boundary.ts +36 -0
  151. package/extensions/lib/compaction-keep.mjs +83 -0
  152. package/extensions/lib/config-mode.ts +112 -0
  153. package/extensions/lib/consent-dialogs.ts +63 -0
  154. package/extensions/lib/consent-preview.ts +5 -1
  155. package/extensions/lib/consent-stores.ts +48 -0
  156. package/extensions/lib/context-facts.ts +97 -0
  157. package/extensions/lib/covered-messages.ts +23 -0
  158. package/extensions/lib/deferred.ts +359 -25
  159. package/extensions/lib/dock-rows.ts +60 -0
  160. package/extensions/lib/empty-tool-result.ts +27 -0
  161. package/extensions/lib/environment-block.ts +76 -0
  162. package/extensions/lib/fetch-timeout.ts +24 -6
  163. package/extensions/{system-prompt → lib}/git-status.ts +51 -13
  164. package/extensions/lib/git.ts +75 -13
  165. package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
  166. package/extensions/lib/grammars/README.md +2 -0
  167. package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
  168. package/extensions/lib/guide-docs.ts +45 -0
  169. package/extensions/lib/headless-output.ts +46 -0
  170. package/extensions/lib/hook-gate.ts +7 -7
  171. package/extensions/lib/interrupt.ts +20 -4
  172. package/extensions/lib/key-input.ts +455 -0
  173. package/extensions/lib/keys.ts +14 -1
  174. package/extensions/lib/ledger/fold-memo.ts +49 -0
  175. package/extensions/lib/ledger/fold.ts +265 -0
  176. package/extensions/lib/ledger/layout.ts +72 -0
  177. package/extensions/lib/ledger/plan.ts +102 -0
  178. package/extensions/lib/ledger/records.ts +276 -0
  179. package/extensions/lib/ledger/state.ts +47 -0
  180. package/extensions/lib/ledger/validate.ts +141 -0
  181. package/extensions/lib/ledger/wire.ts +632 -0
  182. package/extensions/lib/local-command.ts +71 -19
  183. package/extensions/lib/mcp-overrides.ts +1 -1
  184. package/extensions/lib/mcp-share.ts +46 -0
  185. package/extensions/lib/memory-content.ts +146 -0
  186. package/extensions/lib/memory-index.ts +18 -0
  187. package/extensions/lib/memory.ts +134 -123
  188. package/extensions/lib/model-catalog/huggingface.json +178 -0
  189. package/extensions/lib/model-catalog/models-dev.json +18533 -0
  190. package/extensions/lib/model-catalog/openrouter.json +5050 -0
  191. package/extensions/lib/model-catalog-data.ts +513 -0
  192. package/extensions/lib/model-catalog.ts +462 -0
  193. package/extensions/lib/model-choice-warnings.ts +137 -0
  194. package/extensions/lib/model-pairings.ts +152 -0
  195. package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
  196. package/extensions/lib/model-policy.ts +112 -15
  197. package/extensions/lib/model-tier.ts +283 -355
  198. package/extensions/lib/model-unusable.ts +1 -1
  199. package/extensions/lib/newer-model.ts +100 -0
  200. package/extensions/lib/notices.ts +87 -0
  201. package/extensions/lib/notifications.ts +357 -73
  202. package/extensions/lib/one-code-settings.ts +74 -8
  203. package/extensions/lib/open-browser.ts +63 -0
  204. package/extensions/lib/openrouter-generation.ts +94 -0
  205. package/extensions/lib/operation-deadline.ts +30 -0
  206. package/extensions/lib/original-command.ts +4 -4
  207. package/extensions/lib/package-version.ts +1 -1
  208. package/extensions/lib/panel-focus.ts +134 -0
  209. package/extensions/lib/paths.ts +17 -2
  210. package/extensions/lib/permission-gate.ts +72 -6
  211. package/extensions/lib/persisted-output.ts +60 -8
  212. package/extensions/lib/pi-ai-estimate.ts +19 -3
  213. package/extensions/lib/pi-version.ts +38 -2
  214. package/extensions/lib/plan-mode-channels.ts +42 -5
  215. package/extensions/lib/plugins.ts +26 -17
  216. package/extensions/lib/powershell-parse-script.ts +208 -0
  217. package/extensions/lib/powershell-parser.ts +344 -0
  218. package/extensions/lib/previous-mark.ts +101 -0
  219. package/extensions/lib/process-state.ts +48 -0
  220. package/extensions/lib/process-tree.ts +132 -8
  221. package/extensions/lib/prompt-options.ts +82 -0
  222. package/extensions/lib/queued-delivery.ts +44 -0
  223. package/extensions/lib/reminders.ts +370 -358
  224. package/extensions/lib/replaced-builtins.mjs +199 -0
  225. package/extensions/lib/replay-call.ts +70 -0
  226. package/extensions/lib/request-replay.ts +547 -0
  227. package/extensions/lib/scratchpad.ts +81 -14
  228. package/extensions/lib/send-now-channel.ts +7 -0
  229. package/extensions/lib/session-model-tier.ts +51 -0
  230. package/extensions/lib/session-work.ts +64 -0
  231. package/extensions/lib/settings-channels.ts +4 -4
  232. package/extensions/lib/shell-exit-semantics.ts +170 -0
  233. package/extensions/lib/shell-quote.ts +11 -0
  234. package/extensions/lib/shell-result.ts +70 -0
  235. package/extensions/lib/shell-spawn.ts +70 -6
  236. package/extensions/lib/shell-tool.ts +75 -25
  237. package/extensions/lib/side-call-cache.ts +124 -0
  238. package/extensions/lib/side-call-run.ts +96 -0
  239. package/extensions/lib/side-call-usage.ts +38 -0
  240. package/extensions/lib/side-call.ts +24 -1
  241. package/extensions/lib/single-flight.ts +18 -0
  242. package/extensions/lib/skill-body.ts +42 -0
  243. package/extensions/lib/skill-scan.ts +63 -24
  244. package/extensions/lib/spill-file.ts +140 -0
  245. package/extensions/lib/subagent-handback.ts +112 -0
  246. package/extensions/lib/system-role.ts +156 -0
  247. package/extensions/lib/terminal-text.ts +114 -0
  248. package/extensions/lib/text-width.ts +47 -16
  249. package/extensions/lib/timer-ops.ts +20 -0
  250. package/extensions/lib/tool-additions.ts +129 -0
  251. package/extensions/lib/tool-call-corruption.ts +134 -0
  252. package/extensions/lib/tool-path.ts +66 -0
  253. package/extensions/lib/tool-variants.ts +69 -0
  254. package/extensions/lib/tui-render.ts +255 -27
  255. package/extensions/lib/turn-budget-layout.ts +159 -0
  256. package/extensions/lib/update-check.mjs +15 -2
  257. package/extensions/lib/usage-bus.ts +1 -1
  258. package/extensions/lib/user-denial.ts +38 -0
  259. package/extensions/lib/workspace-channel.ts +16 -0
  260. package/extensions/lib/worktree-channel.ts +31 -3
  261. package/extensions/lib/worktree-isolation.ts +40 -10
  262. package/extensions/lsp/client.ts +8 -9
  263. package/extensions/lsp/index.ts +76 -12
  264. package/extensions/lsp/keep-alive.ts +1 -1
  265. package/extensions/lsp/trust.ts +119 -0
  266. package/extensions/lsp/watcher.ts +1 -1
  267. package/extensions/mcp/announce.ts +90 -43
  268. package/extensions/mcp/client.ts +161 -48
  269. package/extensions/mcp/config.ts +132 -8
  270. package/extensions/mcp/discovery.ts +19 -0
  271. package/extensions/mcp/index.ts +220 -121
  272. package/extensions/mcp/oauth/browser.ts +38 -17
  273. package/extensions/mcp/oauth/flow.ts +25 -17
  274. package/extensions/mcp/oauth/provider.ts +14 -8
  275. package/extensions/mcp/oauth/store.ts +34 -16
  276. package/extensions/mcp/panel/keys.ts +12 -11
  277. package/extensions/mcp/panel/render.ts +9 -6
  278. package/extensions/mcp/schema.ts +104 -5
  279. package/extensions/mcp/trust.ts +164 -73
  280. package/extensions/memory/entries.ts +23 -14
  281. package/extensions/memory/index.ts +134 -28
  282. package/extensions/memory/open-external.ts +33 -18
  283. package/extensions/memory/panel.ts +135 -26
  284. package/extensions/model-default/index.ts +86 -19
  285. package/extensions/newer-model/index.ts +31 -0
  286. package/extensions/notebook/description.ts +18 -0
  287. package/extensions/notebook/index.ts +6 -5
  288. package/extensions/notebook/notebook.ts +1 -1
  289. package/extensions/permissions/index.ts +1426 -398
  290. package/extensions/permissions/matcher.ts +425 -74
  291. package/extensions/permissions/modes.ts +11 -4
  292. package/extensions/permissions/panel/host.ts +130 -0
  293. package/extensions/permissions/panel/keys.ts +48 -0
  294. package/extensions/permissions/panel/render.ts +450 -0
  295. package/extensions/permissions/panel/state.ts +550 -0
  296. package/extensions/permissions/powershell-cmdlets.ts +198 -0
  297. package/extensions/permissions/powershell-paths.ts +197 -0
  298. package/extensions/permissions/powershell-rules.ts +207 -280
  299. package/extensions/permissions/powershell-tree.ts +557 -0
  300. package/extensions/permissions/project-trust.ts +27 -11
  301. package/extensions/permissions/protected-paths.ts +14 -2
  302. package/extensions/permissions/session-grant.ts +6 -0
  303. package/extensions/permissions/settings.ts +166 -31
  304. package/extensions/permissions/subagent-gate.ts +25 -0
  305. package/extensions/permissions/workspace.ts +78 -0
  306. package/extensions/plan-mode/index.ts +276 -92
  307. package/extensions/plan-mode/reminder.ts +89 -21
  308. package/extensions/plan-mode/run-state.ts +26 -0
  309. package/extensions/plan-mode/texts.ts +177 -0
  310. package/extensions/plan-mode/viewer.ts +92 -27
  311. package/extensions/plugins/counts.ts +3 -3
  312. package/extensions/plugins/index.ts +64 -46
  313. package/extensions/plugins/install/install.ts +16 -0
  314. package/extensions/plugins/marketplace/sync.ts +9 -3
  315. package/extensions/plugins/marketplace/types.ts +3 -1
  316. package/extensions/plugins/panel/keys.ts +21 -24
  317. package/extensions/plugins/panel/render.ts +12 -6
  318. package/extensions/plugins/shell-expand.ts +107 -0
  319. package/extensions/plugins/template.ts +32 -15
  320. package/extensions/powershell/child.ts +7 -0
  321. package/extensions/powershell/description.ts +38 -6
  322. package/extensions/powershell/guards.ts +1 -1
  323. package/extensions/powershell/index.ts +14 -7
  324. package/extensions/recap/index.ts +73 -71
  325. package/extensions/recap/prompt.ts +2 -2
  326. package/extensions/recap/scheduler.ts +3 -5
  327. package/extensions/search-tools/grep-merge.ts +39 -0
  328. package/extensions/search-tools/index.ts +0 -0
  329. package/extensions/send-now/chord.ts +62 -0
  330. package/extensions/send-now/index.ts +133 -0
  331. package/extensions/session-title/index.ts +3 -0
  332. package/extensions/session-title/title.ts +4 -1
  333. package/extensions/skill/announce.ts +30 -0
  334. package/extensions/skill/code-review.ts +97 -0
  335. package/extensions/skill/index.ts +469 -132
  336. package/extensions/skill/invoke.ts +118 -75
  337. package/extensions/skill/listing.ts +133 -9
  338. package/extensions/skill/panel/keys.ts +15 -16
  339. package/extensions/skill/panel/render.ts +5 -2
  340. package/extensions/skill/panel/state.ts +12 -1
  341. package/extensions/subagents/agent-args.ts +39 -0
  342. package/extensions/subagents/agent-description.ts +132 -0
  343. package/extensions/subagents/agents.ts +146 -31
  344. package/extensions/subagents/default-model.ts +2 -3
  345. package/extensions/subagents/delegation-steer.ts +4 -4
  346. package/extensions/subagents/fork-cache.ts +82 -0
  347. package/extensions/subagents/guide-agent.ts +189 -0
  348. package/extensions/subagents/hand-back-review.ts +3 -0
  349. package/extensions/subagents/index.ts +1594 -852
  350. package/extensions/subagents/live-runs.ts +26 -6
  351. package/extensions/subagents/model-select.ts +38 -25
  352. package/extensions/subagents/outcome.ts +5 -2
  353. package/extensions/subagents/panel-keys.ts +73 -31
  354. package/extensions/subagents/panel-render.ts +120 -96
  355. package/extensions/subagents/panel-widget.ts +60 -89
  356. package/extensions/subagents/parent-mode.ts +50 -0
  357. package/extensions/subagents/pending-claim.ts +18 -1
  358. package/extensions/subagents/runner.ts +135 -80
  359. package/extensions/subagents/runs.ts +44 -5
  360. package/extensions/subagents/send-to-main-tool.ts +1 -1
  361. package/extensions/subagents/session-turns.ts +32 -8
  362. package/extensions/subagents/shell-panel.ts +36 -178
  363. package/extensions/subagents/tasks-dialog.ts +170 -0
  364. package/extensions/subagents/viewport-mirror.ts +95 -0
  365. package/extensions/subagents/worktree.ts +57 -13
  366. package/extensions/system-prompt/environment.ts +19 -19
  367. package/extensions/system-prompt/idle-turn.ts +65 -0
  368. package/extensions/system-prompt/index.ts +165 -47
  369. package/extensions/system-prompt/template.ts +34 -51
  370. package/extensions/system-prompt/tiers/common.ts +23 -64
  371. package/extensions/system-prompt/tiers/frontier.ts +32 -11
  372. package/extensions/system-prompt/tiers/low.ts +67 -45
  373. package/extensions/system-prompt/tiers/mid.ts +96 -38
  374. package/extensions/system-reminder/index.ts +204 -70
  375. package/extensions/tasks/descriptions.ts +190 -0
  376. package/extensions/tasks/index.ts +120 -41
  377. package/extensions/tasks/store.ts +83 -16
  378. package/extensions/tool-call-corruption/child.ts +7 -0
  379. package/extensions/tool-call-corruption/index.ts +237 -0
  380. package/extensions/tool-search/index.ts +366 -144
  381. package/extensions/tool-style/index.ts +68 -10
  382. package/extensions/tool-style/write-description.ts +40 -0
  383. package/extensions/tool-style/write-render.ts +237 -0
  384. package/extensions/turn-duration/index.ts +7 -3
  385. package/extensions/turn-duration/line.ts +47 -4
  386. package/extensions/web/backends.ts +6 -9
  387. package/extensions/web/description.ts +50 -0
  388. package/extensions/web/index.ts +118 -53
  389. package/extensions/web/native-call.ts +15 -0
  390. package/extensions/web/native-results.ts +41 -0
  391. package/extensions/web-fetch/description.ts +42 -0
  392. package/extensions/web-fetch/extract.ts +53 -6
  393. package/extensions/web-fetch/index.ts +125 -76
  394. package/extensions/web-fetch/page-content.ts +19 -0
  395. package/extensions/web-fetch/response.ts +49 -0
  396. package/extensions/web-fetch/summarize.ts +9 -10
  397. package/extensions/workflow/agent-session.ts +108 -18
  398. package/extensions/workflow/args.ts +24 -0
  399. package/extensions/workflow/description.ts +67 -0
  400. package/extensions/workflow/globals.ts +17 -3
  401. package/extensions/workflow/index.ts +157 -162
  402. package/extensions/workflow/records.ts +1 -0
  403. package/extensions/workflow/run-manager.ts +33 -6
  404. package/extensions/workflow/saved-workflows.ts +3 -2
  405. package/extensions/workflow/types.ts +22 -1
  406. package/extensions/workflow/viewer.ts +54 -24
  407. package/extensions/workflow/widget.ts +20 -0
  408. package/extensions/worktree/descriptions.ts +91 -0
  409. package/extensions/worktree/guards.ts +316 -54
  410. package/extensions/worktree/index.ts +162 -44
  411. package/extensions/worktree/powershell-guards.ts +167 -0
  412. package/extensions/worktree/rewrite.ts +14 -16
  413. package/package.json +21 -7
  414. package/skills/artifact-design/SKILL.md +85 -0
  415. package/skills/code-review/SKILL.md +20 -20
  416. package/skills/code-review/cells/arguments-and-flags.md +19 -0
  417. package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
  418. package/skills/code-review/cells/high-8-inline.md +122 -0
  419. package/skills/code-review/cells/low-1-pass-max4.md +27 -0
  420. package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
  421. package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
  422. package/skills/code-review/cells/medium-3x5-verify.md +132 -0
  423. package/skills/code-review/cells/minimal-prompt.md +32 -0
  424. package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
  425. package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
  426. package/skills/fewer-permission-prompts/SKILL.md +1 -1
  427. package/skills/loop/SKILL.md +11 -0
  428. package/skills/security-review/SKILL.md +2 -5
  429. package/skills/simplify/SKILL.md +7 -5
  430. package/skills/workflow-authoring/SKILL.md +143 -0
  431. package/themes/onecode-light.json +1 -1
  432. package/themes/onecode.json +1 -1
  433. package/types/app.d.ts +58 -0
  434. package/types/community-packages.d.ts +26 -1
  435. package/extensions/auto-mode/recoverability.ts +0 -229
  436. package/extensions/lib/capability-index.ts +0 -466
  437. package/extensions/lib/model-facts.generated.json +0 -2675
  438. package/extensions/lib/model-facts.ts +0 -162
  439. package/extensions/tool-search/announce.ts +0 -42
@@ -1,24 +1,32 @@
1
1
  /**
2
2
  * background extension — Claude Code's background-task surface:
3
- * monitor, task_output, task_stop, schedule_wakeup.
3
+ * monitor, task_output, task_stop, schedule_wakeup, cron_create, cron_list,
4
+ * cron_delete, and /loop.
4
5
  *
5
6
  * Owns the BackgroundRegistry. Other extensions (subagents) register their
6
7
  * long-running work over TASK_REGISTER_CHANNEL at runtime, so task_output and
7
8
  * task_stop address every background task in the session regardless of which
8
9
  * extension started it. Events and completions are delivered as steered
9
10
  * task notifications (lib/notifications.ts), never as user input; a fired
10
- * wakeup or loop tick re-invokes the session with its prompt verbatim.
11
+ * wakeup or cron job re-invokes the session with its prompt verbatim.
12
+ *
13
+ * Every scheduled prompt lives in one CronStore (cron.ts): cron_create jobs
14
+ * and schedule_wakeup's one-shot wakeups (wakeup.ts). One timer is armed to
15
+ * the store's next fire; jobs fire only while the agent is idle, so a job
16
+ * that came due during a turn fires once at agent_settled. `/loop` is
17
+ * Claude Code's skill (skills/loop, its body built by loop-skill.ts): the
18
+ * model schedules the loop itself.
11
19
  */
12
20
 
13
21
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
22
  import { Type } from "typebox";
15
23
  import { whenAborted } from "../lib/abort.ts";
16
24
  import { DEFER_CHANNEL } from "../lib/deferred.ts";
17
- import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
18
- import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree, waitForChildExit } from "../lib/process-tree.ts";
25
+ import { persistedFilePreview, persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
26
+ import { detachedSpawnOptions, KILL_GRACE_MS, rememberProcessGroup, stopProcessTree, waitForChildExit } from "../lib/process-tree.ts";
19
27
  import { sessionAlive } from "../lib/session-lifecycle.ts";
20
28
  import { bashSpawn, spawnShellCommand } from "../lib/shell-spawn.ts";
21
- import { ccToolRenderers, customMessageText, liveUiCtx, notificationComponent, scheduledTaskComponent } from "../lib/tui-render.ts";
29
+ import { ccToolRenderers, customMessageText, formatFireTime, liveUiCtx, notificationComponent, notificationUserMarkdown, scheduledTaskComponent } from "../lib/tui-render.ts";
22
30
  import {
23
31
  type BackgroundTask,
24
32
  BackgroundRegistry,
@@ -26,16 +34,47 @@ import {
26
34
  generateTaskId,
27
35
  TASK_REGISTER_CHANNEL,
28
36
  } from "./registry.ts";
37
+ import { SWITCH_CANCEL, SWITCH_STOP, switchWarning, workWidgetLine } from "./switch-guard.ts";
29
38
  import {
30
- buildDynamicLoopPrompt,
31
- buildLoopMessage,
32
- buildWakeupMessage,
33
- clampDelaySeconds,
34
- describeSchedule,
35
- MAX_DELAY_SECONDS,
36
- MIN_DELAY_SECONDS,
37
- parseLoopArgs,
39
+ AGED_OUT_RESULT,
40
+ DynamicLoop,
41
+ formatScheduled,
42
+ formatStopped,
43
+ SCHEDULE_WAKEUP_DESCRIPTION,
44
+ SCHEDULE_WAKEUP_PARAMS,
45
+ WAKEUP_ERRORS,
38
46
  } from "./wakeup.ts";
47
+ import {
48
+ deliveryFromState,
49
+ isAutonomousSentinel,
50
+ isLoopFileSentinel,
51
+ LOOP_DELIVERY_DETAILS_KEY,
52
+ LOOP_DELIVERY_STATE,
53
+ type LoopDeliveryState,
54
+ readLoopFile,
55
+ resolveLoopFire,
56
+ } from "./loop-fire.ts";
57
+ import { autonomousPreamble, loopSkillPrompt } from "./loop-skill.ts";
58
+ import {
59
+ CRON_CREATE_DESCRIPTION,
60
+ CRON_CREATE_PARAMETERS,
61
+ CRON_DELETE_DESCRIPTION,
62
+ CRON_DELETE_PARAMETERS,
63
+ CRON_LIST_DESCRIPTION,
64
+ CRON_LIST_PARAMETERS,
65
+ CronStore,
66
+ type CronFire,
67
+ type CronJob,
68
+ describeCadence,
69
+ formatCannotFire,
70
+ formatCreateResult,
71
+ formatDeleteResult,
72
+ formatJobList,
73
+ formatUnknownJob,
74
+ JOB_FIRED_ENTRY,
75
+ scheduledJobsOnBranch,
76
+ WAKEUP_ARMED_ENTRY,
77
+ } from "./cron.ts";
39
78
  import {
40
79
  createTaskNotifier,
41
80
  monitorEndedSummary,
@@ -55,29 +94,105 @@ import {
55
94
  MONITOR_BATCH_MAX_CHARS,
56
95
  MONITOR_BATCH_IDLE_MS,
57
96
  type MonitorBatch,
97
+ MonitorLineSplitter,
58
98
  pushEvent,
59
99
  } from "./monitor-batch.ts";
60
100
  import { registerLocalCommand } from "../lib/local-command.ts";
101
+ import { applyNoopFolds, decideFold, type FoldDetails, type FoldEntry, foldSuffix } from "./noop-fold.ts";
102
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
103
+ import { AGENT_CRON_CHANNEL, AGENT_CRON_FIRE_CHANNEL, type AgentCronFire, type AgentCronRequest, type FireConversation, formatNotOwner } from "../lib/agent-cron.ts";
104
+ import { SKILL_BODY_CHANNEL, type SkillBodyQuery, SLASH_EXPAND_CHANNEL, type SlashExpandQuery } from "../lib/skill-body.ts";
105
+ import { BUNDLED_SKILLS_DIR } from "../lib/skill-scan.ts";
106
+ import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
107
+ import { join, resolve } from "node:path";
108
+ import { StringDecoder } from "node:string_decoder";
109
+ import { SESSION_WORK_CHANNEL, sessionBackgroundTask, sessionCron, type SessionWorkQuery } from "../lib/session-work.ts";
110
+ import { WORKTREE_CHANNEL, type WorktreeLocation } from "../lib/worktree-channel.ts";
111
+ import { queueNotice } from "../lib/notices.ts";
112
+ import { makeRecord, RECORD } from "../lib/ledger/records.ts";
113
+ import { recordState, stateSinceCompaction } from "../lib/ledger/state.ts";
114
+ import { processState } from "../lib/process-state.ts";
115
+ import { extensionVersion } from "../lib/package-version.ts";
61
116
 
62
- const OUTPUT_CAP = 30_000;
117
+ /** Claude Code's `task_output` limit (`TASK_MAX_OUTPUT_DEFAULT`). */
118
+ const TASK_OUTPUT_MAX_CHARS = 32_000;
63
119
  const STORED_OUTPUT_CAP = 200_000;
64
120
  const DEFAULT_MONITOR_TIMEOUT_MS = 300_000;
65
121
  const MAX_MONITOR_TIMEOUT_MS = 3_600_000;
66
122
  const MAX_BLOCK_TIMEOUT_MS = 600_000;
123
+ /** A finished monitor that saw nothing says so, never a blank a model reads as "still running" (bash's EMPTY_OUTPUT_MARKER). */
124
+ const MONITOR_EMPTY_OUTPUT = "(no output — the monitor received no events and nothing on stderr)";
125
+ /** Heads a monitor's output when the start is gone: past the in-memory cap with no spool file to hold it. */
126
+ const SPOOL_LOST_NOTE = `[The monitor's spool file could not be written, so only the last ${STORED_OUTPUT_CAP.toLocaleString("en-US")} characters of its output were kept.]\n`;
127
+ /** setTimeout's longest delay; a later fire re-arms when this one wakes. */
128
+ const MAX_TIMER_MS = 2 ** 31 - 1;
67
129
  function tail(text: string, cap: number): string {
68
130
  return text.length <= cap ? text : `… (earlier output truncated)\n${text.slice(-cap)}`;
69
131
  }
70
132
 
71
133
  /**
72
- * `/clear`, `/new` and `/resume` stop every background task with the session
73
- * they belong to (the notifier that would report them is inert by then, and
74
- * the new session has no handle on them). Say so, once, in the NEW session:
75
- * the old session's UI is torn down right after `session_shutdown`, so a
76
- * notice printed there is lost. Module scope on purpose — jiti keeps this
77
- * module across the factory re-run that builds the new instance, which is the
78
- * only thing that survives the swap (LIFECYCLE-REVIEW-2026-09-06 L3).
134
+ * `/clear`, `/new`, `/resume` and `/reload` stop every background task with
135
+ * the session they belong to (the notifier that would report them is inert by
136
+ * then, and the new instance has no handle on them). Say so, once, in the NEW
137
+ * session: the old session's UI is torn down right after `session_shutdown`,
138
+ * so a notice printed there is lost (LIFECYCLE-REVIEW-2026-09-06 L3). The
139
+ * notice is process state (lib/process-state.ts): module scope would lose it
140
+ * wherever pi evaluates this module again, at `/reload` and at a `/resume`
141
+ * into another directory (lifecycle grid R3-L13).
142
+ */
143
+ const shutdownNotice = () => processState<{ text?: string }>("background:shutdown-notice", () => ({}));
144
+
145
+ /**
146
+ * `<sessionDir>/monitor/<taskId>/output.log`, beside background shells'
147
+ * `bash/<taskId>/`. A session-less run spools under the session's private
148
+ * results dir (`sessionResultsDir`), so its output is never kept only as the
149
+ * in-memory tail. Undefined when the folder cannot be made.
150
+ */
151
+ function monitorLogPath(ctx: ExtensionContext, taskId: string): string | undefined {
152
+ try {
153
+ const dir = join(sessionResultsDir(ctx), "monitor", taskId);
154
+ mkdirSync(dir, { recursive: true });
155
+ return join(dir, "output.log");
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * `task_output`'s body. Past `TASK_OUTPUT_MAX_CHARS` a task with a spool file
163
+ * gets Claude Code's clip, `[Truncated. Full output: <path>]` and the tail
164
+ * that fits, since the file holds the full text; a task without one (a
165
+ * subagent's report) is persisted whole to the session's tool-results
166
+ * folder, never cut (decisions/tools.md).
79
167
  */
80
- let pendingShutdownNotice: string | undefined;
168
+ function taskOutputBody(task: BackgroundTask, ctx: ExtensionContext): string {
169
+ const output = task.output();
170
+ if (output.length <= TASK_OUTPUT_MAX_CHARS) return output;
171
+ if (task.logPath) {
172
+ const header = `[Truncated. Full output: ${task.logPath}]\n\n`;
173
+ return `${header}${output.slice(-(TASK_OUTPUT_MAX_CHARS - header.length))}`;
174
+ }
175
+ return persistIfLarge(output, { dir: sessionResultsDir(ctx), id: `task-output-${task.id}`, maxBytes: TASK_OUTPUT_MAX_CHARS });
176
+ }
177
+
178
+ /** A session entry as the no-op fold reads it (noop-fold.ts). */
179
+ function foldEntry(entry: { type: string; timestamp?: string; customType?: string; details?: unknown; message?: unknown }): FoldEntry {
180
+ if (entry.type === "compaction") return { kind: "compaction" };
181
+ if (entry.type === "custom_message") {
182
+ if (entry.customType === "wakeup") return { kind: "wakeup", timestamp: Date.parse(entry.timestamp ?? "") || 0, details: entry.details as FoldDetails | undefined };
183
+ return entry.customType === "cron" ? { kind: "fire" } : { kind: "other" };
184
+ }
185
+ if (entry.type !== "message") return { kind: "other" };
186
+ const message = entry.message as { role?: string; content?: unknown; stopReason?: string; toolCallId?: string };
187
+ if (message.role === "user") return { kind: "user" };
188
+ if (message.role === "toolResult") return { kind: "toolResult", toolCallId: message.toolCallId ?? "" };
189
+ if (message.role === "assistant") {
190
+ const blocks = Array.isArray(message.content) ? (message.content as Array<{ type?: string; id?: string; name?: string; arguments?: { noop?: unknown } }>) : [];
191
+ const toolCalls = blocks.filter((b) => b.type === "toolCall").map((b) => ({ id: b.id ?? "", name: b.name ?? "", noop: b.arguments?.noop }));
192
+ return { kind: "assistant", toolCalls, aborted: message.stopReason === "aborted" };
193
+ }
194
+ return { kind: "other" };
195
+ }
81
196
 
82
197
  export default function backgroundExtension(pi: ExtensionAPI) {
83
198
  // After session_shutdown the captured ctx throws on every access and the
@@ -88,32 +203,68 @@ export default function backgroundExtension(pi: ExtensionAPI) {
88
203
  const alive = sessionAlive(pi);
89
204
  const registry = new BackgroundRegistry();
90
205
  let lastCtx: ExtensionContext | undefined;
91
- let wakeup: { timer: NodeJS.Timeout; prompt: string; reason: string } | undefined;
92
- // Fixed-interval /loop (harness-driven, auto-re-arming — distinct from the
93
- // model-driven `wakeup` used by dynamic /loop). One at a time, like wakeup.
94
- let loop: { timer: NodeJS.Timeout; intervalSeconds: number; task: string } | undefined;
95
- // True between agent_start and agent_settled, so a fixed-interval tick can skip
96
- // (not queue) while the previous tick's turn is still running — no backlog.
97
- // agent_settled, not agent_end: agent_end can precede an internal retry /
98
- // auto-compaction / queued continuation, and a tick landing in that gap would
99
- // inject a turn while the prior one is still about to resume.
206
+ const cron = new CronStore();
207
+ const dynamicLoop = new DynamicLoop(cron);
208
+ let sessionCwd = process.cwd();
209
+ // The main session's worktree while it is in one (enter_worktree), where its fires resolve.
210
+ let worktreeCwd: string | undefined;
211
+ pi.events.on(WORKTREE_CHANNEL, (data) => {
212
+ worktreeCwd = (data as WorktreeLocation | null)?.path;
213
+ });
214
+ let cronTimer: NodeJS.Timeout | undefined;
215
+ // True between agent_start and agent_settled, so a due cron job waits for the
216
+ // turn to end instead of injecting one mid-run. agent_settled, not agent_end:
217
+ // agent_end can precede an internal retry / auto-compaction / queued
218
+ // continuation, and a fire landing in that gap would inject a turn while the
219
+ // prior one is still about to resume.
100
220
  let agentBusy = false;
101
- // Set when a dynamic /loop force-activated schedule_wakeup, so /loop stop can
102
- // restore the lean tool surface it changed.
103
- let activatedWakeupTool = false;
104
221
 
105
- pi.events.on(TASK_REGISTER_CHANNEL, (task) => registry.register(task as BackgroundTask));
222
+ pi.events.on(TASK_REGISTER_CHANNEL, (data) => {
223
+ const task = data as BackgroundTask;
224
+ registry.register(task);
225
+ // Shells and subagent runs count toward the widget's warning line too.
226
+ updateWidget();
227
+ task.finished?.then(updateWidget, updateWidget);
228
+ });
229
+ // A subagent's cron tools (lib/agent-cron.ts): its jobs, its view, its deletes.
230
+ pi.events.on(AGENT_CRON_CHANNEL, (data) => {
231
+ const request = data as AgentCronRequest;
232
+ if (request.op === "create") {
233
+ const created = cron.create({ cron: request.cron, prompt: request.prompt, recurring: request.recurring, agentId: request.agentId, cwd: request.cwd }, Date.now());
234
+ request.result = created.ok
235
+ ? { text: formatCreateResult(created.job), details: { jobId: created.job.id } }
236
+ : { text: created.error, isError: true };
237
+ if (created.ok) runCron();
238
+ } else if (request.op === "list") {
239
+ const jobs = cron.list().filter((job) => job.agentId === request.agentId);
240
+ request.result = { text: formatJobList(jobs), details: { jobCount: jobs.length } };
241
+ } else {
242
+ const job = cron.get(request.id);
243
+ if (!job) request.result = { text: formatUnknownJob(request.id), isError: true };
244
+ else if (job.agentId !== request.agentId) request.result = { text: formatNotOwner(request.id), isError: true };
245
+ else {
246
+ cron.delete(request.id);
247
+ runCron();
248
+ request.result = { text: formatDeleteResult(request.id) };
249
+ }
250
+ }
251
+ });
252
+ // The Stop hook's session_crons / background_tasks (lib/session-work.ts).
253
+ pi.events.on(SESSION_WORK_CHANNEL, (data) => {
254
+ const query = data as SessionWorkQuery;
255
+ query.crons.push(...cron.list().map(sessionCron));
256
+ query.tasks.push(...registry.running().map(sessionBackgroundTask));
257
+ });
106
258
 
107
259
  const updateWidget = () => {
108
260
  // liveUiCtx: a stale ctx (session replaced) reads as "no UI", never a throw;
109
261
  // session_shutdown also drops lastCtx, so a late repaint is a no-op.
110
262
  const live = liveUiCtx(lastCtx);
111
263
  if (!live) return;
112
- // A task whose producer gives it first-class panel UI (bash shells and
113
- // subagent runs in the subagents panel) is excluded, or this line would
114
- // stay permanently lit next to the panel already showing the same work.
115
- const running = registry.running().filter((t) => !t.ownUI).length;
116
- live.ui.setWidget("cc-background", running > 0 ? [` background tasks: ${running} running`] : undefined);
264
+ // Every running task and scheduled job, panel-owned ones included: the
265
+ // line is the warning of what /clear, /reload or quitting stops (switch-guard.ts).
266
+ const line = workWidgetLine(registry.running().length, cron.list().length);
267
+ live.ui.setWidget("cc-background", line ? [line] : undefined);
117
268
  };
118
269
 
119
270
  // The wire frames are model-facing; the transcript shows a compact headline
@@ -123,19 +274,146 @@ export default function backgroundExtension(pi: ExtensionAPI) {
123
274
  pi.registerMessageRenderer("task-notification", (message, { expanded }, theme) =>
124
275
  notificationComponent(theme, customMessageText(message.content), expanded),
125
276
  );
126
- for (const customType of ["wakeup", "loop"]) {
127
- pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
128
- scheduledTaskComponent(theme, customMessageText(message.content), (message as { timestamp?: number }).timestamp ?? Date.now(), expanded),
129
- );
130
- }
131
- // The dynamic /loop's opening turn is the user's command, not a fired task.
132
- pi.registerMessageRenderer("loop-start", (message, { expanded }, theme) =>
133
- notificationComponent(theme, customMessageText(message.content), expanded),
277
+ // A notification that opens the session's first turn is a user message
278
+ // (lib/notifications.ts, "First turn of a session"); pi draws those as
279
+ // markdown, so the same headline replaces the frames here. Every notifier's
280
+ // messages pass through this one transformer.
281
+ pi.registerMarkdownTransformer((markdown, context) =>
282
+ context.messageType === "user" ? (notificationUserMarkdown(markdown) ?? markdown) : markdown,
134
283
  );
284
+ for (const customType of ["wakeup", "cron"]) {
285
+ // Claude Code: "Claude resuming /loop wakeup (…)" for a wakeup; the model here may not be Claude.
286
+ const label = customType === "wakeup" ? "Resuming /loop wakeup" : undefined;
287
+ pi.registerMessageRenderer(customType, (message, { expanded }, theme) => {
288
+ const fold = message.details as FoldDetails | undefined;
289
+ const suffix = fold?.noOpStreak ? foldSuffix(fold.noOpStreak, formatFireTime(fold.streakStartedAt ?? Date.now())) : "";
290
+ return scheduledTaskComponent(theme, customMessageText(message.content), (message as { timestamp?: number }).timestamp ?? Date.now(), expanded, label, suffix);
291
+ });
292
+ }
135
293
 
136
294
  // A monitor's end the model already read through task_output is withdrawn
137
295
  // like a shell's (lib/notifications.ts).
138
- const notify = createTaskNotifier(pi, { withdrawOnDelivery: true });
296
+ const notify = createTaskNotifier(pi, {
297
+ withdrawOnDelivery: true,
298
+ // A fire's message reached the conversation: what it told the model is on the branch from here.
299
+ onDelivered: (customType, details) => {
300
+ if (customType !== "cron" && customType !== "wakeup") return;
301
+ const stored = details[LOOP_DELIVERY_DETAILS_KEY];
302
+ if (stored !== undefined) recordDelivery(pi, deliveryFromState(stored));
303
+ },
304
+ });
305
+
306
+ /**
307
+ * A no-prompt /loop sentinel's text (loop-fire.ts), against what the
308
+ * conversation it lands in was already sent. That record is branch state
309
+ * of that conversation: the main session's, or a subagent's own session,
310
+ * since each has its own history and compacts on its own. Read since the
311
+ * last compaction, so the first fire after one resends the instructions,
312
+ * as Claude Code does. `delivery` is the record this text makes true, set
313
+ * only when it changes (lifecycle grid R3-L12); the caller writes it when
314
+ * the text reaches the conversation.
315
+ */
316
+ const expandSentinel = (prompt: string, cwd: string, conversation: FireConversation | undefined): { text: string; delivery?: LoopDeliveryState } => {
317
+ let branch: readonly unknown[] = [];
318
+ try {
319
+ branch = conversation?.getBranch() ?? [];
320
+ } catch {
321
+ // A torn-down session reads as one that was sent nothing.
322
+ }
323
+ const before = deliveryFromState(stateSinceCompaction(branch, LOOP_DELIVERY_STATE));
324
+ const state = { ...before };
325
+ const text = resolveLoopFire(state, prompt, cwd, autonomousPreamble());
326
+ const changed = state.autonomousPreambleDelivered !== before.autonomousPreambleDelivered || state.lastLoopFileDelivered !== before.lastLoopFileDelivered;
327
+ return changed ? { text, delivery: state } : { text };
328
+ };
329
+ /** Record what a delivered fire told its conversation; a torn-down one has nothing to record into. */
330
+ const recordDelivery = (conversation: Pick<FireConversation, "appendEntry">, delivery: LoopDeliveryState) => {
331
+ try {
332
+ recordState(conversation, LOOP_DELIVERY_STATE, delivery);
333
+ } catch {
334
+ // Same: nothing to record into.
335
+ }
336
+ };
337
+ /** The main session as a fire's conversation, while it is live. */
338
+ const mainConversation = (): FireConversation | undefined => {
339
+ const ctx = lastCtx;
340
+ return ctx ? { getBranch: () => ctx.sessionManager.getBranch(), appendEntry: (customType, data) => pi.appendEntry(customType, data) } : undefined;
341
+ };
342
+
343
+ /**
344
+ * The text a fired prompt delivers, as Claude Code's queue would run it: a
345
+ * no-prompt /loop sentinel expands (loop-fire.ts), and a slash command that
346
+ * names a skill runs that skill (lib/skill-body.ts); anything else is the
347
+ * prompt verbatim. Both resolve in the job owner's directory: a subagent's
348
+ * own, or the main session's worktree while it is in one.
349
+ */
350
+ const fireText = (job: CronJob, conversation: FireConversation | undefined): { text: string; delivery?: LoopDeliveryState } => {
351
+ const { prompt } = job;
352
+ const cwd = job.cwd ?? worktreeCwd ?? sessionCwd;
353
+ if (isAutonomousSentinel(prompt) || isLoopFileSentinel(prompt)) return expandSentinel(prompt, cwd, conversation);
354
+ if (!prompt.startsWith("/")) return { text: prompt };
355
+ const query: SlashExpandQuery = { text: prompt, cwd };
356
+ pi.events.emit(SLASH_EXPAND_CHANNEL, query);
357
+ return { text: query.expanded ?? prompt };
358
+ };
359
+ /** Claude Code's no-op fold for the wakeup firing now (noop-fold.ts), read off the session branch. */
360
+ const foldDetails = (): FoldDetails => {
361
+ let entries: FoldEntry[];
362
+ try {
363
+ entries = (lastCtx?.sessionManager.getBranch() ?? []).map(foldEntry);
364
+ } catch {
365
+ return {};
366
+ }
367
+ const decision = decideFold(entries);
368
+ return decision.kind === "fold" ? { noOpStreak: decision.priorStreak + 1, streakStartedAt: decision.since } : {};
369
+ };
370
+ const fireCron = (fire: CronFire) => {
371
+ const { job, final } = fire;
372
+ if (job.agentId !== undefined) {
373
+ // A subagent's job goes to that agent, or is dropped once it has ended.
374
+ // The agent's resident sends the text as soon as it asks for it, so the record goes with the text.
375
+ const text = (conversation: FireConversation | undefined) => {
376
+ const fired = fireText(job, conversation);
377
+ if (fired.delivery && conversation) recordDelivery(conversation, fired.delivery);
378
+ return fired.text;
379
+ };
380
+ const delivery: AgentCronFire = { agentId: job.agentId, jobId: job.id, text };
381
+ pi.events.emit(AGENT_CRON_FIRE_CHANNEL, delivery);
382
+ // The agent has ended: its job goes with it.
383
+ if (!delivery.delivered) cron.delete(job.id);
384
+ return;
385
+ }
386
+ const fold = job.source === "wakeup" ? foldDetails() : {};
387
+ // A fold removes the tick's messages from the model's context: above the
388
+ // append point, so the next request opens an epoch (extensions/ledger)
389
+ // and renders the folded context whole, once.
390
+ if (fold.noOpStreak !== undefined) pi.appendEntry(RECORD.epoch, makeRecord("epoch", extensionVersion(), { cause: "fold" }));
391
+ if (job.source === "wakeup") dynamicLoop.inFlight = job.prompt;
392
+ // Recorded whatever way the notification goes out, so a /reload knows the fire happened.
393
+ pi.appendEntry(JOB_FIRED_ENTRY, { jobId: job.id, final, source: job.source });
394
+ // The notifier may hold the message (a coalescing window, an interrupt) and
395
+ // a /reload drops what it holds, so the loop's record rides the message and
396
+ // is written when it is delivered (the notifier's onDelivered above).
397
+ const { text, delivery } = fireText(job, mainConversation());
398
+ const details = { jobId: job.id, cron: job.cron, source: job.source, final, ...fold, ...(delivery ? { [LOOP_DELIVERY_DETAILS_KEY]: delivery } : {}) };
399
+ notify(job.source === "wakeup" ? "wakeup" : "cron", text, details);
400
+ };
401
+ /** Fire every due job (only while idle), then arm the one timer to the next fire. */
402
+ const runCron = () => {
403
+ if (cronTimer) clearTimeout(cronTimer);
404
+ cronTimer = undefined;
405
+ // A busy agent re-runs this at agent_settled; a dead session never does.
406
+ if (!alive() || agentBusy) {
407
+ updateWidget();
408
+ return;
409
+ }
410
+ for (const fire of cron.takeDue(Date.now())) fireCron(fire);
411
+ updateWidget();
412
+ const next = cron.nextFireAt();
413
+ if (next === undefined) return;
414
+ cronTimer = setTimeout(runCron, Math.min(Math.max(0, next - Date.now()), MAX_TIMER_MS));
415
+ cronTimer.unref?.();
416
+ };
139
417
 
140
418
  pi.registerTool({
141
419
  name: "monitor",
@@ -180,6 +458,32 @@ export default function backgroundExtension(pi: ExtensionAPI) {
180
458
  const id = generateTaskId();
181
459
  let status: BackgroundTask["status"] = "running";
182
460
  let stored = "";
461
+ // The whole stream, spooled like a background shell's, so the batches'
462
+ // "task_output has the full stream" and task_output's clip header point
463
+ // at something true, and a one-shot result past the in-memory cap can
464
+ // still persist every line. Opened on the first output.
465
+ let log: WriteStream | undefined;
466
+ let spoolFailed = false;
467
+ let overflowed = false;
468
+ const record = (text: string) => {
469
+ if (stored.length + text.length > STORED_OUTPUT_CAP) overflowed = true;
470
+ stored = tail(`${stored}${text}`, STORED_OUTPUT_CAP);
471
+ if (!text || spoolFailed) return;
472
+ if (!log) {
473
+ const path = monitorLogPath(ctx, id);
474
+ if (!path) return;
475
+ log = createWriteStream(path, { flags: "a" });
476
+ log.on("error", () => {
477
+ // Best-effort, as bash's spool: the in-memory tail stays
478
+ // authoritative, and nothing names a missing or partial file as
479
+ // the full output any more.
480
+ spoolFailed = true;
481
+ task.logPath = undefined;
482
+ });
483
+ task.logPath = path;
484
+ }
485
+ log.write(text);
486
+ };
183
487
  let eventCount = 0;
184
488
  let pending: MonitorBatch = emptyBatch();
185
489
  let flushTimer: NodeJS.Timeout | undefined;
@@ -188,9 +492,16 @@ export default function backgroundExtension(pi: ExtensionAPI) {
188
492
  finish = resolve;
189
493
  });
190
494
 
191
- const flush = () => {
495
+ const flush = (force = false) => {
192
496
  flushTimer = undefined;
193
497
  if (batchSize(pending) === 0) return;
498
+ if (!force && notify.holding()) {
499
+ // Held after an interrupt: one bounded batch accumulates (its
500
+ // overflow counted) instead of one held message per flush, and
501
+ // goes out once the next turn starts.
502
+ flushTimer = setTimeout(() => flush(), MONITOR_BATCH_IDLE_MS);
503
+ return;
504
+ }
194
505
  const batch = pending;
195
506
  pending = emptyBatch();
196
507
  // CC's mid-run monitor batch: no status, the lines in `<event>`.
@@ -201,48 +512,71 @@ export default function backgroundExtension(pi: ExtensionAPI) {
201
512
  );
202
513
  };
203
514
 
515
+ // Set by stop(): what the command prints while it is torn down (a shell
516
+ // running the rest of `sleep 30; echo done` before the SIGKILL grace ends)
517
+ // is not an event, so the count says what happened before the stop.
518
+ let stopRequested = false;
204
519
  const onEvent = (line: string) => {
520
+ if (stopRequested) return;
205
521
  eventCount++;
206
- stored = tail(`${stored}${line}\n`, STORED_OUTPUT_CAP);
522
+ record(`${line}\n`);
207
523
  if (oneShot) return; // collected into the tool result instead
208
524
  pushEvent(pending, line);
209
525
  // Bounded batches, and a wider window mid-turn so a chatty stream
210
526
  // coalesces instead of steering one notification per second
211
527
  // (monitor-batch.ts).
212
- flushTimer ??= setTimeout(flush, agentBusy ? MONITOR_BATCH_BUSY_MS : MONITOR_BATCH_IDLE_MS);
528
+ flushTimer ??= setTimeout(() => flush(), agentBusy ? MONITOR_BATCH_BUSY_MS : MONITOR_BATCH_IDLE_MS);
213
529
  };
214
530
 
215
531
  const end = (finalStatus: BackgroundTask["status"], note?: string) => {
216
532
  if (status !== "running") return;
217
533
  status = finalStatus;
218
- task.status = finalStatus;
219
- task.finishedAt = Date.now();
220
534
  if (flushTimer) clearTimeout(flushTimer);
221
- finish();
222
- // Past this point everything touches the session: a monitor ending
223
- // after shutdown (H1) or inside a one-shot run reports through
224
- // `finished` alone.
225
- if (!alive() || oneShot) return;
226
- flush();
227
- updateWidget();
228
- // CC's monitor end: the recent tail rides `<event>` under the ended summary.
229
- const ccStatus = taskStatusOf(finalStatus);
230
- const recent = tail(stored, MONITOR_BATCH_MAX_CHARS).trim();
231
- notify(
232
- "task-notification",
233
- taskNotification({
234
- kind: "monitor",
235
- taskId: id,
236
- toolUseId: toolCallId,
237
- status: ccStatus,
238
- summary: monitorEndedSummary(params.description, ccStatus, eventCount > 0, note),
239
- result: recent || undefined,
240
- }),
241
- { taskId: id, status: finalStatus },
242
- );
535
+ const complete = () => {
536
+ if (task.status !== "running") return;
537
+ // Terminal status and its notification promise readable final output.
538
+ task.status = finalStatus;
539
+ task.finishedAt = Date.now();
540
+ finish();
541
+ // After shutdown or inside a one-shot run, report through `finished` alone.
542
+ if (!alive() || oneShot) return;
543
+ flush(true);
544
+ updateWidget();
545
+ // CC's monitor end: the recent tail rides <event> under the ended summary.
546
+ const ccStatus = taskStatusOf(finalStatus);
547
+ const recent = tail(stored, MONITOR_BATCH_MAX_CHARS).trim();
548
+ notify(
549
+ "task-notification",
550
+ taskNotification({
551
+ kind: "monitor",
552
+ taskId: id,
553
+ toolUseId: toolCallId,
554
+ status: ccStatus,
555
+ summary: monitorEndedSummary(params.description, ccStatus, eventCount > 0, note),
556
+ result: recent || undefined,
557
+ }),
558
+ { taskId: id, status: finalStatus },
559
+ );
560
+ };
561
+ if (log && !log.closed) {
562
+ // `close` follows a clean end and an error alike. A stuck stream
563
+ // must not hold a blocking tool forever or advertise a partial file.
564
+ const fallback = setTimeout(() => {
565
+ spoolFailed = true;
566
+ task.logPath = undefined;
567
+ log?.destroy();
568
+ complete();
569
+ }, 1_000);
570
+ log.once("close", () => {
571
+ clearTimeout(fallback);
572
+ complete();
573
+ });
574
+ log.end();
575
+ } else {
576
+ complete();
577
+ }
243
578
  };
244
579
 
245
- let stopRequested = false;
246
580
  let stop: () => void;
247
581
  if (params.command) {
248
582
  // Own process group: stop() must end the COMMAND, not just the `$SHELL -c`
@@ -263,27 +597,32 @@ export default function backgroundExtension(pi: ExtensionAPI) {
263
597
  ...detachedSpawnOptions(),
264
598
  stdio: ["ignore", "pipe", "pipe"],
265
599
  });
266
- let buffer = "";
600
+ rememberProcessGroup(child);
601
+ const lines = new MonitorLineSplitter();
602
+ // Streaming decoders: a UTF-8 character split across reads stays whole.
603
+ const stdoutText = new StringDecoder("utf8");
604
+ const stderrText = new StringDecoder("utf8");
267
605
  child.stdout?.on("data", (chunk: Buffer) => {
268
- buffer += chunk.toString();
269
- let idx: number;
270
- while ((idx = buffer.indexOf("\n")) !== -1) {
271
- const line = buffer.slice(0, idx).trimEnd();
272
- buffer = buffer.slice(idx + 1);
273
- if (line) onEvent(line);
274
- }
606
+ for (const line of lines.push(stdoutText.write(chunk))) onEvent(line);
275
607
  });
276
608
  child.stderr?.on("data", (chunk: Buffer) => {
277
- stored = tail(`${stored}${chunk.toString()}`, STORED_OUTPUT_CAP);
609
+ record(stderrText.write(chunk));
278
610
  });
279
611
  // Exit plus a short stdio grace, not `close` (lib/process-tree.ts).
612
+ // The last line may have no newline; it is an event all the same.
613
+ const drain = () => {
614
+ for (const line of [...lines.push(stdoutText.end()), ...lines.end()]) onEvent(line);
615
+ record(stderrText.end());
616
+ };
280
617
  waitForChildExit(child).then(
281
- ({ code }) =>
282
- end(
283
- stopRequested ? "stopped" : code === 0 ? "completed" : "failed",
284
- code !== null && code !== 0 ? `exit code ${code}` : undefined,
285
- ),
286
- (error: Error) => end("failed", error.message),
618
+ ({ code }) => {
619
+ drain();
620
+ end(stopRequested ? "stopped" : code === 0 ? "completed" : "failed", code !== null && code !== 0 ? `exit code ${code}` : undefined);
621
+ },
622
+ (error: Error) => {
623
+ drain();
624
+ end("failed", error.message);
625
+ },
287
626
  );
288
627
  stop = () => {
289
628
  stopRequested = true;
@@ -322,7 +661,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
322
661
  description: params.description,
323
662
  status,
324
663
  startedAt: Date.now(),
325
- output: () => stored,
664
+ output: () => (overflowed && !task.logPath ? `${SPOOL_LOST_NOTE}${stored}` : stored || (task.status === "running" ? "" : MONITOR_EMPTY_OUTPUT)),
326
665
  stop,
327
666
  finished,
328
667
  };
@@ -349,7 +688,11 @@ export default function backgroundExtension(pi: ExtensionAPI) {
349
688
  } finally {
350
689
  unhook();
351
690
  }
352
- const output = persistIfLarge(stored.trim() || "(no events)", { dir: sessionResultsDir(ctx), id: `monitor-${id}` });
691
+ // Past the in-memory cap the spool holds every line: point at it rather
692
+ // than persist the tail. Without one, say what was lost.
693
+ const spooled = overflowed && task.logPath ? persistedFilePreview(task.logPath) : undefined;
694
+ const kept = overflowed && !spooled ? `${SPOOL_LOST_NOTE}${stored.trim()}` : stored.trim() || "(no events)";
695
+ const output = spooled ?? persistIfLarge(kept, { dir: sessionResultsDir(ctx), id: `monitor-${id}` });
353
696
  const finalStatus = task.status;
354
697
  let how: string = finalStatus;
355
698
  if (finalStatus === "stopped") {
@@ -407,22 +750,26 @@ export default function backgroundExtension(pi: ExtensionAPI) {
407
750
 
408
751
  if (params.block !== false && task.status === "running") {
409
752
  const timeoutMs = Math.min(params.timeout ?? 30_000, MAX_BLOCK_TIMEOUT_MS);
410
- await Promise.race([
411
- task.finished,
412
- new Promise<void>((resolve) => {
413
- const timer = setTimeout(resolve, timeoutMs);
414
- timer.unref?.();
415
- signal?.addEventListener("abort", () => {
416
- clearTimeout(timer);
417
- resolve();
418
- }, { once: true });
419
- }),
420
- ]);
753
+ let timer: ReturnType<typeof setTimeout> | undefined;
754
+ let unhook = () => {};
755
+ try {
756
+ await Promise.race([
757
+ task.finished,
758
+ new Promise<void>((resolve) => {
759
+ // The tool awaits this deadline: keep it referenced in one-shot runs.
760
+ timer = setTimeout(resolve, timeoutMs);
761
+ unhook = whenAborted(signal, resolve);
762
+ }),
763
+ ]);
764
+ } finally {
765
+ clearTimeout(timer);
766
+ unhook();
767
+ }
421
768
  }
422
769
 
423
770
  updateWidget();
424
771
  const header = formatTaskLine(task);
425
- const body = tail(task.output(), OUTPUT_CAP) || "(no output yet)";
772
+ const body = taskOutputBody(task, ctx) || "(no output yet)";
426
773
  // The model now holds a finished task's output: a completion notification
427
774
  // still waiting to go out for it is redundant (lib/notifications.ts).
428
775
  if (task.status !== "running") pi.events.emit(TASK_OUTPUT_DELIVERED_CHANNEL, { taskId: task.id } satisfies TaskOutputDelivered);
@@ -437,9 +784,12 @@ export default function backgroundExtension(pi: ExtensionAPI) {
437
784
  name: "task_stop",
438
785
  label: "Stop Task",
439
786
  ...ccToolRenderers("Stop Task"),
440
- description: "Stop a running background task (monitor, background subagent, or background bash) by task id.",
787
+ // Claude Code's TaskStop text without its agent-team and agent-name clauses:
788
+ // a task is stopped by its id here.
789
+ description:
790
+ "\n- Stops a running background task by its ID\n- Takes a task_id parameter identifying the task to stop\n- Returns a success or failure status\n- Use this tool when you need to terminate a long-running task\n",
441
791
  parameters: Type.Object({
442
- task_id: Type.String({ description: "The id of the background task to stop" }),
792
+ task_id: Type.String({ description: "The ID of the background task to stop." }),
443
793
  }),
444
794
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
445
795
  lastCtx = ctx;
@@ -463,7 +813,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
463
813
  {
464
814
  type: "text",
465
815
  text: wasResident
466
- ? `Terminated the resident agent behind ${task.id} (${task.description}); it can no longer be messaged live (send_message will resume it from its session file).`
816
+ ? `Terminated the resident agent behind ${task.id} (${task.description}); it can no longer be messaged live (SendMessage will resume it from its session file).`
467
817
  : `Stop requested for ${task.id} (${task.description}).`,
468
818
  },
469
819
  ],
@@ -478,61 +828,91 @@ export default function backgroundExtension(pi: ExtensionAPI) {
478
828
  ...ccToolRenderers<{ delaySeconds?: number; stop?: boolean }>("Schedule Wakeup", {
479
829
  title: (a) => (a?.stop ? "stop" : a?.delaySeconds !== undefined ? `${a.delaySeconds}s` : undefined),
480
830
  }),
481
- description:
482
- "Schedule when to resume work on a self-paced recurring task — the dynamic mode of the /loop command. After `delaySeconds` (clamped to [60, 3600]) the harness re-invokes you with the given prompt, verbatim, as the next turn's input. One wakeup is pending at a time — scheduling again replaces it; {stop: true} ends the loop and cancels any pending wakeup.\n\nPass the same task back via `prompt` each turn so the next firing repeats it. Set `noop: true` when nothing changed this tick (you checked and there's nothing to report); `noop: false` when something happened worth keeping. Pick `delaySeconds` from what you're actually waiting for: poll external state (a CI run, a deploy) at the rate it changes; for a quiet idle heartbeat prefer a long delay (1200s+). Do NOT schedule a short wakeup just to poll background work you started here — its completion already notifies you.",
831
+ description: SCHEDULE_WAKEUP_DESCRIPTION,
483
832
  parameters: Type.Object({
484
- delaySeconds: Type.Optional(Type.Number({ description: "Seconds from now to wake up, clamped to [60, 3600]. Required unless stop is true" })),
485
- prompt: Type.Optional(Type.String({ description: "The task to continue when the wakeup fires. Required unless stop is true" })),
486
- reason: Type.Optional(Type.String({ description: "One short sentence explaining the chosen delay; shown to the user. Required unless stop is true" })),
487
- noop: Type.Optional(Type.Boolean({ description: "true when nothing changed this tick (quiet hold); false when something happened worth keeping" })),
488
- stop: Type.Optional(Type.Boolean({ description: "End the loop: cancel any pending wakeup and schedule nothing" })),
833
+ delaySeconds: Type.Optional(Type.Number({ description: SCHEDULE_WAKEUP_PARAMS.delaySeconds })),
834
+ reason: Type.Optional(Type.String({ description: SCHEDULE_WAKEUP_PARAMS.reason })),
835
+ prompt: Type.Optional(Type.String({ description: SCHEDULE_WAKEUP_PARAMS.prompt })),
836
+ stop: Type.Optional(Type.Boolean({ description: SCHEDULE_WAKEUP_PARAMS.stop })),
837
+ noop: Type.Optional(Type.Boolean({ description: SCHEDULE_WAKEUP_PARAMS.noop })),
489
838
  }),
490
839
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
491
840
  lastCtx = ctx;
492
- const clearPending = () => {
493
- if (wakeup) {
494
- clearTimeout(wakeup.timer);
495
- wakeup = undefined;
496
- }
497
- };
498
- if (params.stop) {
499
- clearPending();
500
- return { content: [{ type: "text", text: "Wakeup loop stopped; no further wakeups will fire." }], details: {} };
501
- }
502
- // Validate BEFORE touching the pending wakeup: clearing it up front meant
503
- // a malformed reschedule silently killed a running /loop with no signal.
504
- if (params.delaySeconds === undefined || !params.prompt || !params.reason) {
505
- return {
506
- content: [
507
- {
508
- type: "text",
509
- text: `delaySeconds, prompt, and reason are all required unless stop is true.${wakeup ? " Your previous wakeup is still pending — this call was rejected and left it untouched." : ""}`,
510
- },
511
- ],
512
- details: {},
513
- isError: true,
514
- };
841
+ const fail = (text: string) => ({ content: [{ type: "text" as const, text }], details: {}, isError: true });
842
+ if (params.stop === true) {
843
+ const cancelled = dynamicLoop.stop();
844
+ runCron();
845
+ return { content: [{ type: "text", text: formatStopped(cancelled) }], details: { stopped: true, cancelledWakeups: cancelled } };
515
846
  }
516
- // Valid reschedule — now it is safe to replace any pending wakeup.
517
- clearPending();
518
-
519
- const request = { delaySeconds: params.delaySeconds, prompt: params.prompt, reason: params.reason, noop: params.noop };
520
- const delayMs = clampDelaySeconds(params.delaySeconds) * 1000;
521
- const timer = setTimeout(() => {
522
- wakeup = undefined;
523
- notify("wakeup", buildWakeupMessage(request), { reason: request.reason, noop: request.noop ?? false });
524
- }, delayMs);
525
- timer.unref?.();
526
- wakeup = { timer, prompt: params.prompt, reason: params.reason };
527
- return { content: [{ type: "text", text: describeSchedule(request) }], details: { delayMs, noop: params.noop ?? false } };
847
+ if (params.delaySeconds === undefined || params.reason === undefined) return fail(WAKEUP_ERRORS.delayAndReason);
848
+ if (params.prompt === undefined) return fail(WAKEUP_ERRORS.prompt);
849
+ if (params.noop === undefined) return fail(WAKEUP_ERRORS.noop);
850
+ const now = Date.now();
851
+ const scheduled = dynamicLoop.schedule(params.delaySeconds, params.prompt, now);
852
+ runCron();
853
+ if (!scheduled) return { content: [{ type: "text", text: AGED_OUT_RESULT }], details: { scheduledFor: 0 } };
854
+ const text = formatScheduled(scheduled, now);
855
+ return {
856
+ content: [{ type: "text", text: sessionOutlivesTurn(ctx.mode) ? text : `${text} ${formatCannotFire()}` }],
857
+ details: { ...scheduled, reason: params.reason, noop: params.noop },
858
+ };
859
+ },
860
+ });
861
+
862
+ pi.registerTool({
863
+ name: "cron_create",
864
+ label: "Cron Create",
865
+ ...ccToolRenderers<{ cron?: string }>("Cron Create", { title: (a) => a?.cron }),
866
+ description: CRON_CREATE_DESCRIPTION,
867
+ parameters: CRON_CREATE_PARAMETERS,
868
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
869
+ lastCtx = ctx;
870
+ const created = cron.create({ cron: params.cron, prompt: params.prompt, recurring: params.recurring }, Date.now());
871
+ if (!created.ok) return { content: [{ type: "text", text: created.error }], details: {}, isError: true };
872
+ runCron();
873
+ const text = formatCreateResult(created.job);
874
+ return {
875
+ content: [{ type: "text", text: sessionOutlivesTurn(ctx.mode) ? text : `${text} ${formatCannotFire()}` }],
876
+ details: { jobId: created.job.id, cron: created.job.cron, recurring: created.job.recurring, nextFireAt: created.job.nextFireAt },
877
+ };
878
+ },
879
+ });
880
+
881
+ pi.registerTool({
882
+ name: "cron_list",
883
+ label: "Cron List",
884
+ ...ccToolRenderers("Cron List"),
885
+ description: CRON_LIST_DESCRIPTION,
886
+ parameters: CRON_LIST_PARAMETERS,
887
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
888
+ lastCtx = ctx;
889
+ const jobs = cron.list();
890
+ return { content: [{ type: "text", text: formatJobList(jobs) }], details: { count: jobs.length } };
528
891
  },
529
892
  });
530
893
 
894
+ pi.registerTool({
895
+ name: "cron_delete",
896
+ label: "Cron Delete",
897
+ ...ccToolRenderers<{ id?: string }>("Cron Delete", { title: (a) => a?.id }),
898
+ description: CRON_DELETE_DESCRIPTION,
899
+ parameters: CRON_DELETE_PARAMETERS,
900
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
901
+ lastCtx = ctx;
902
+ if (!cron.delete(params.id)) return { content: [{ type: "text", text: formatUnknownJob(params.id) }], details: {}, isError: true };
903
+ runCron();
904
+ return { content: [{ type: "text", text: formatDeleteResult(params.id) }], details: { jobId: params.id } };
905
+ },
906
+ });
907
+
908
+ // schedule_wakeup stays eager, as Claude Code's ScheduleWakeup is.
531
909
  for (const [name, keywords] of Object.entries({
532
910
  monitor: ["monitor", "watch", "background", "tail", "events", "stream", "websocket"],
533
911
  task_output: ["task", "background", "output", "status", "wait"],
534
912
  task_stop: ["task", "background", "stop", "kill", "cancel"],
535
- schedule_wakeup: ["wakeup", "loop", "schedule", "timer", "recurring", "later"],
913
+ cron_create: ["cron", "schedule", "recurring", "remind", "timer", "every", "later"],
914
+ cron_list: ["cron", "schedule", "list", "jobs", "scheduled"],
915
+ cron_delete: ["cron", "schedule", "cancel", "delete", "stop"],
536
916
  })) {
537
917
  pi.events.emit(DEFER_CHANNEL, { name, keywords });
538
918
  }
@@ -545,100 +925,45 @@ export default function backgroundExtension(pi: ExtensionAPI) {
545
925
  },
546
926
  });
547
927
 
548
- const clearLoop = () => {
549
- if (loop) {
550
- clearInterval(loop.timer);
551
- loop = undefined;
552
- }
553
- };
554
- const clearWakeup = () => {
555
- if (wakeup) {
556
- clearTimeout(wakeup.timer);
557
- wakeup = undefined;
928
+ // `/loop` is a bundled skill whose body depends on its arguments; build it
929
+ // for the skill extension (lib/skill-body.ts). Only our own SKILL.md: a
930
+ // project skill named "loop" keeps its file's body.
931
+ const loopSkillPath = join(BUNDLED_SKILLS_DIR, "loop", "SKILL.md");
932
+ pi.events.on(SKILL_BODY_CHANNEL, (data) => {
933
+ const query = data as SkillBodyQuery;
934
+ if (query.skill !== "loop" || resolve(query.path) !== loopSkillPath) return;
935
+ query.body = loopSkillPrompt(query.args, readLoopFile(query.cwd));
936
+ });
937
+
938
+ /** Arm the branch's scheduled jobs, with each wakeup's loop age and keepalive spend. */
939
+ const armFromBranch = (ctx: ExtensionContext) => {
940
+ const now = Date.now();
941
+ for (const job of scheduledJobsOnBranch(ctx.sessionManager.getBranch() as never)) {
942
+ if (!cron.restore(job, now) || job.source !== "wakeup") continue;
943
+ dynamicLoop.restoreChain(job.prompt, job.chainStartedAt ?? job.createdAt, job.fireAt ?? now, job.keepalive === true);
558
944
  }
945
+ runCron();
559
946
  };
560
-
561
- registerLocalCommand(pi, "loop", {
562
- description: "Repeat a task on a loop: `/loop 5m <task>` (fixed interval) or `/loop <task>` (self-paced). `/loop stop` ends it.",
563
- handler: async (args, ctx) => {
564
- lastCtx = ctx;
565
- const raw = (args ?? "").trim();
566
- const keyword = raw.toLowerCase();
567
-
568
- if (keyword === "stop") {
569
- const had = Boolean(loop || wakeup);
570
- clearLoop();
571
- clearWakeup();
572
- if (activatedWakeupTool) {
573
- const active = pi.getActiveTools();
574
- if (active.includes("schedule_wakeup")) pi.setActiveTools(active.filter((t) => t !== "schedule_wakeup"));
575
- activatedWakeupTool = false;
576
- }
577
- ctx.ui.notify(had ? "Loop stopped; no further iterations will fire." : "No loop was running.", "info");
578
- return;
579
- }
580
- if (raw === "" || keyword === "status") {
581
- const parts: string[] = [];
582
- if (loop) parts.push(`fixed interval every ${loop.intervalSeconds}s — ${loop.task}`);
583
- if (wakeup) parts.push(`self-paced wakeup pending — ${wakeup.reason}`);
584
- ctx.ui.notify(
585
- parts.length
586
- ? `Running:\n- ${parts.join("\n- ")}\nStop with /loop stop.`
587
- : "No loop running. Start with `/loop [interval] <task>`, e.g. `/loop 5m check the build`.",
588
- "info",
589
- );
590
- return;
591
- }
592
-
593
- const parsed = parseLoopArgs(raw);
594
- if (!parsed.task) {
595
- ctx.ui.notify("Give a task to loop, e.g. `/loop 10m check for new PRs` or `/loop watch the deploy`.", "info");
596
- return;
597
- }
598
-
599
- // One loop at a time — replace any existing interval loop or pending wakeup.
600
- clearLoop();
601
- clearWakeup();
602
-
603
- if (parsed.intervalSeconds !== undefined) {
604
- // Fixed interval, harness-driven: re-arm automatically and fire the first tick now.
605
- const seconds = clampDelaySeconds(parsed.intervalSeconds);
606
- const adjusted =
607
- seconds !== parsed.intervalSeconds
608
- ? ` (adjusted from ${parsed.intervalSeconds}s; allowed range ${MIN_DELAY_SECONDS}-${MAX_DELAY_SECONDS}s)`
609
- : "";
610
- // Skip (don't queue) a tick while the previous tick's turn is still
611
- // running, so a slow task can't build a backlog of stale iterations.
612
- const tick = () => {
613
- if (agentBusy) return;
614
- notify("loop", buildLoopMessage(parsed.task), { intervalSeconds: seconds });
615
- };
616
- const timer = setInterval(tick, seconds * 1000);
617
- timer.unref?.();
618
- loop = { timer, intervalSeconds: seconds, task: parsed.task };
619
- ctx.ui.notify(`Looping every ${seconds}s${adjusted}: ${parsed.task}. Stop with /loop stop.`, "info");
620
- tick(); // fire the first iteration now
621
- return;
622
- }
623
-
624
- // Dynamic (self-paced): the model drives schedule_wakeup, so make sure it is
625
- // active — remembering we did, so /loop stop can restore the lean surface.
626
- const active = pi.getActiveTools();
627
- if (!active.includes("schedule_wakeup")) {
628
- pi.setActiveTools([...active, "schedule_wakeup"]);
629
- activatedWakeupTool = true;
630
- }
631
- ctx.ui.notify(`Self-paced loop started: ${parsed.task}. Stop with /loop stop.`, "info");
632
- notify("loop-start", buildDynamicLoopPrompt(parsed.task), {});
633
- },
947
+ // The jobs follow the branch: a /tree switch ends the abandoned branch's and
948
+ // arms the selected one's (a subagent's own jobs stay with the agent).
949
+ pi.on("session_tree", (_event, ctx) => {
950
+ lastCtx = ctx;
951
+ cron.deleteWhere((job) => job.agentId === undefined);
952
+ dynamicLoop.clear();
953
+ armFromBranch(ctx);
634
954
  });
635
-
636
- pi.on("session_start", (_event, ctx) => {
955
+ pi.on("session_start", (event, ctx) => {
637
956
  lastCtx = ctx;
638
- if (pendingShutdownNotice) {
639
- const notice = pendingShutdownNotice;
640
- pendingShutdownNotice = undefined;
641
- if (ctx.hasUI) ctx.ui.notify(notice, "info");
957
+ sessionCwd = ctx.cwd;
958
+ // A /reload keeps the process and the conversation, so the session's
959
+ // scheduled jobs come back from the branch (design.md section 13); a new
960
+ // process ends them, as Claude Code's session-only jobs end.
961
+ if (event.reason === "reload") armFromBranch(ctx);
962
+ const pending = shutdownNotice();
963
+ if (pending.text) {
964
+ const notice = pending.text;
965
+ pending.text = undefined;
966
+ if (ctx.hasUI) queueNotice(pi.events, ctx, "info", notice);
642
967
  }
643
968
  });
644
969
  pi.on("agent_start", () => {
@@ -646,22 +971,63 @@ export default function backgroundExtension(pi: ExtensionAPI) {
646
971
  });
647
972
  pi.on("agent_settled", () => {
648
973
  agentBusy = false;
974
+ // Claude Code's keepalive: a wakeup's turn that scheduled no next one gets
975
+ // one fallback, recorded so a /reload re-arms it (no tool result does).
976
+ if (dynamicLoop.settle(Date.now()) === "armed") {
977
+ const armed = cron.list().find((job) => job.source === "wakeup");
978
+ if (armed) pi.appendEntry(WAKEUP_ARMED_ENTRY, { id: armed.id, prompt: armed.prompt, fireAt: armed.nextFireAt, startedAt: dynamicLoop.chainStart(armed.prompt) });
979
+ }
980
+ runCron();
981
+ });
982
+ // Folded no-op ticks leave the model's context (noop-fold.ts); the note stands in for them.
983
+ pi.on("context", (event) => {
984
+ const messages = applyNoopFolds(event.messages as Array<AgentMessage & { role: string }>, (text, timestamp) => ({ role: "user", content: [{ type: "text", text }], timestamp }) as AgentMessage & { role: string });
985
+ return messages ? { messages } : undefined;
649
986
  });
987
+ // A switch stops everything below, so ask first. pi can cancel a switch but
988
+ // not a quit; the widget line covers quitting (switch-guard.ts).
989
+ const confirmSwitch = async (ctx: ExtensionContext, action: string): Promise<{ cancel: true } | undefined> => {
990
+ if (!ctx.hasUI) return undefined;
991
+ const title = switchWarning(
992
+ registry.running().map((t) => ({ id: t.id, label: t.description })),
993
+ cron.list().map((j) => ({ id: j.id, label: describeCadence(j.cron) })),
994
+ action,
995
+ );
996
+ if (!title) return undefined;
997
+ const choice = await ctx.ui.select(title, [SWITCH_CANCEL, SWITCH_STOP]);
998
+ return choice === SWITCH_STOP ? undefined : { cancel: true };
999
+ };
1000
+ pi.on("session_before_switch", (event, ctx) =>
1001
+ confirmSwitch(ctx, event.reason === "resume" ? "Resuming another session" : "Starting a new session"),
1002
+ );
1003
+ pi.on("session_before_fork", (_event, ctx) => confirmSwitch(ctx, "Forking the session"));
650
1004
 
651
1005
  pi.on("session_shutdown", (event) => {
652
1006
  // Fires on /clear, /new, /resume and /reload too (findings §8). Everything
653
1007
  // running dies with this instance — the replacement instance has no handle
654
- // on it — and the replacement is told (see pendingShutdownNotice); a quit
1008
+ // on it — and the replacement is told (see shutdownNotice); a quit
655
1009
  // needs no note. A reload keeps the conversation, so its note says so.
656
1010
  const running = registry.running();
657
- if (running.length > 0 && event.reason !== "quit") {
658
- const names = running.map((t) => `${t.id} (${t.description})`).join(", ");
1011
+ // A reload re-arms the jobs from the branch: only the tasks stop.
1012
+ const jobs = event.reason === "reload" ? [] : cron.list();
1013
+ if ((running.length > 0 || jobs.length > 0) && event.reason !== "quit") {
659
1014
  const when = event.reason === "reload" ? "on reload" : "with the previous session";
660
- pendingShutdownNotice = `Stopped ${running.length} background task${running.length === 1 ? "" : "s"} ${when}: ${names}.`;
1015
+ const notices: string[] = [];
1016
+ if (running.length > 0) {
1017
+ const names = running.map((t) => `${t.id} (${t.description})`).join(", ");
1018
+ notices.push(`Stopped ${running.length} background task${running.length === 1 ? "" : "s"} ${when}: ${names}.`);
1019
+ }
1020
+ if (jobs.length > 0) {
1021
+ const names = jobs.map((j) => `${j.id} (${describeCadence(j.cron)})`).join(", ");
1022
+ notices.push(`Cancelled ${jobs.length} scheduled job${jobs.length === 1 ? "" : "s"} ${when}: ${names}.`);
1023
+ }
1024
+ shutdownNotice().text = notices.join(" ");
661
1025
  }
662
1026
  registry.stopAll();
663
- clearWakeup();
664
- clearLoop();
1027
+ cron.clear();
1028
+ dynamicLoop.clear();
1029
+ if (cronTimer) clearTimeout(cronTimer);
1030
+ cronTimer = undefined;
665
1031
  lastCtx = undefined;
666
1032
  });
667
1033
  }