one-code-extension 0.4.1 → 0.5.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 (419) 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 +32 -23
  7. package/docs/guide/doctor.md +20 -13
  8. package/docs/guide/hooks.md +18 -3
  9. package/docs/guide/installation.md +27 -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 +123 -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 +515 -0
  56. package/extensions/background/index.ts +514 -236
  57. package/extensions/background/loop-fire.ts +136 -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 +52 -0
  63. package/extensions/background/wakeup.ts +159 -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 +88 -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 +229 -71
  77. package/extensions/btw/panel.ts +228 -100
  78. package/extensions/btw/prompt.ts +41 -4
  79. package/extensions/claude-compat/index.ts +7 -11
  80. package/extensions/claude-context/index.ts +446 -58
  81. package/extensions/compaction/cut.ts +157 -0
  82. package/extensions/compaction/index.ts +216 -75
  83. package/extensions/compaction/kept-thinking.ts +62 -0
  84. package/extensions/compaction/prompt.ts +15 -12
  85. package/extensions/context-budget/index.ts +24 -5
  86. package/extensions/context-management/index.ts +25 -0
  87. package/extensions/doctor/build.ts +59 -8
  88. package/extensions/doctor/builtins.ts +84 -0
  89. package/extensions/doctor/cli.ts +7 -1
  90. package/extensions/doctor/compat.ts +88 -23
  91. package/extensions/doctor/dependencies.ts +41 -5
  92. package/extensions/doctor/fix-prompt.ts +9 -2
  93. package/extensions/doctor/index.ts +102 -58
  94. package/extensions/doctor/models.ts +102 -63
  95. package/extensions/doctor/presets.ts +42 -27
  96. package/extensions/doctor/report.ts +19 -2
  97. package/extensions/doctor/viewer.ts +49 -27
  98. package/extensions/effort/index.ts +72 -19
  99. package/extensions/effort/settings.ts +21 -0
  100. package/extensions/effort/slider.ts +24 -20
  101. package/extensions/exit/index.ts +32 -1
  102. package/extensions/file-tracker/index.ts +337 -26
  103. package/extensions/file-tracker/replay.ts +96 -10
  104. package/extensions/file-tracker/restore.ts +63 -0
  105. package/extensions/file-tracker/results.ts +66 -0
  106. package/extensions/file-tracker/shell-reads.ts +211 -0
  107. package/extensions/file-tracker/tracker.ts +32 -15
  108. package/extensions/footer/footer-line.ts +45 -24
  109. package/extensions/footer/index.ts +47 -2
  110. package/extensions/footer/pr.ts +34 -5
  111. package/extensions/hooks/compaction.ts +7 -0
  112. package/extensions/hooks/executor.ts +32 -30
  113. package/extensions/hooks/index.ts +198 -31
  114. package/extensions/hooks/matcher.ts +3 -0
  115. package/extensions/hooks/protocol.ts +41 -3
  116. package/extensions/hooks/settings.ts +50 -19
  117. package/extensions/hooks/subagent-bridge.ts +2 -0
  118. package/extensions/hooks/trust.ts +13 -5
  119. package/extensions/idle-compact/index.ts +119 -0
  120. package/extensions/idle-compact/policy.ts +143 -0
  121. package/extensions/init/index.ts +11 -10
  122. package/extensions/init/prompt.ts +25 -32
  123. package/extensions/interrupted/index.ts +13 -1
  124. package/extensions/kept-thinking/index.ts +27 -0
  125. package/extensions/lib/agent-cron.ts +130 -0
  126. package/extensions/lib/agent-loader.ts +15 -1
  127. package/extensions/lib/agent-view.ts +85 -0
  128. package/extensions/lib/anthropic-payload.ts +82 -1
  129. package/extensions/lib/anthropic-server-call.ts +230 -0
  130. package/extensions/lib/anthropic-server-tools.ts +485 -0
  131. package/extensions/lib/app-launch.mjs +94 -0
  132. package/extensions/lib/argument-hints.ts +190 -0
  133. package/extensions/lib/atomic-write.ts +6 -2
  134. package/extensions/lib/auto-memory.ts +75 -0
  135. package/extensions/lib/bash-parser.ts +84 -0
  136. package/extensions/lib/btw-fork.ts +93 -0
  137. package/extensions/lib/child-extensions.ts +42 -0
  138. package/extensions/lib/child-writes.ts +45 -0
  139. package/extensions/lib/claude-context.ts +515 -100
  140. package/extensions/lib/claude-external-includes.ts +68 -0
  141. package/extensions/lib/claude-id.ts +22 -0
  142. package/extensions/lib/claude-rules.ts +323 -0
  143. package/extensions/lib/claude-settings.ts +76 -4
  144. package/extensions/lib/compaction-boundary.ts +36 -0
  145. package/extensions/lib/compaction-keep.mjs +83 -0
  146. package/extensions/lib/config-mode.ts +112 -0
  147. package/extensions/lib/consent-dialogs.ts +63 -0
  148. package/extensions/lib/consent-preview.ts +5 -1
  149. package/extensions/lib/consent-stores.ts +48 -0
  150. package/extensions/lib/context-facts.ts +78 -0
  151. package/extensions/lib/context-stack.ts +81 -0
  152. package/extensions/lib/deferred.ts +219 -25
  153. package/extensions/lib/dock-rows.ts +60 -0
  154. package/extensions/lib/empty-tool-result.ts +27 -0
  155. package/extensions/lib/environment-block.ts +76 -0
  156. package/extensions/lib/fetch-timeout.ts +24 -6
  157. package/extensions/{system-prompt → lib}/git-status.ts +51 -13
  158. package/extensions/lib/git.ts +75 -13
  159. package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
  160. package/extensions/lib/grammars/README.md +2 -0
  161. package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
  162. package/extensions/lib/guide-docs.ts +45 -0
  163. package/extensions/lib/headless-output.ts +46 -0
  164. package/extensions/lib/hook-gate.ts +7 -7
  165. package/extensions/lib/interrupt.ts +20 -4
  166. package/extensions/lib/key-input.ts +455 -0
  167. package/extensions/lib/keys.ts +14 -1
  168. package/extensions/lib/local-command.ts +47 -13
  169. package/extensions/lib/mcp-overrides.ts +1 -1
  170. package/extensions/lib/mcp-share.ts +46 -0
  171. package/extensions/lib/memory-content.ts +146 -0
  172. package/extensions/lib/memory-index.ts +18 -0
  173. package/extensions/lib/memory.ts +134 -123
  174. package/extensions/lib/model-catalog/huggingface.json +174 -0
  175. package/extensions/lib/model-catalog/models-dev.json +18297 -0
  176. package/extensions/lib/model-catalog/openrouter.json +5252 -0
  177. package/extensions/lib/model-catalog-data.ts +513 -0
  178. package/extensions/lib/model-catalog.ts +462 -0
  179. package/extensions/lib/model-choice-warnings.ts +137 -0
  180. package/extensions/lib/model-pairings.ts +152 -0
  181. package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
  182. package/extensions/lib/model-policy.ts +112 -15
  183. package/extensions/lib/model-tier.ts +283 -355
  184. package/extensions/lib/model-unusable.ts +1 -1
  185. package/extensions/lib/newer-model.ts +100 -0
  186. package/extensions/lib/notices.ts +87 -0
  187. package/extensions/lib/notifications.ts +308 -64
  188. package/extensions/lib/one-code-settings.ts +74 -8
  189. package/extensions/lib/open-browser.ts +63 -0
  190. package/extensions/lib/openrouter-generation.ts +94 -0
  191. package/extensions/lib/operation-deadline.ts +30 -0
  192. package/extensions/lib/original-command.ts +4 -4
  193. package/extensions/lib/package-version.ts +1 -1
  194. package/extensions/lib/panel-focus.ts +134 -0
  195. package/extensions/lib/paths.ts +17 -2
  196. package/extensions/lib/permission-gate.ts +72 -6
  197. package/extensions/lib/persisted-output.ts +60 -8
  198. package/extensions/lib/pi-ai-estimate.ts +19 -3
  199. package/extensions/lib/pi-version.ts +21 -2
  200. package/extensions/lib/plan-mode-channels.ts +20 -5
  201. package/extensions/lib/plugins.ts +26 -17
  202. package/extensions/lib/powershell-parse-script.ts +208 -0
  203. package/extensions/lib/powershell-parser.ts +344 -0
  204. package/extensions/lib/process-tree.ts +131 -8
  205. package/extensions/lib/prompt-options.ts +82 -0
  206. package/extensions/lib/queued-delivery.ts +44 -0
  207. package/extensions/lib/reminders.ts +364 -58
  208. package/extensions/lib/replaced-builtins.mjs +199 -0
  209. package/extensions/lib/replay-call.ts +70 -0
  210. package/extensions/lib/request-replay.ts +439 -0
  211. package/extensions/lib/scratchpad.ts +81 -14
  212. package/extensions/lib/send-now-channel.ts +7 -0
  213. package/extensions/lib/session-model-tier.ts +51 -0
  214. package/extensions/lib/session-work.ts +64 -0
  215. package/extensions/lib/settings-channels.ts +4 -4
  216. package/extensions/lib/shell-exit-semantics.ts +170 -0
  217. package/extensions/lib/shell-quote.ts +11 -0
  218. package/extensions/lib/shell-result.ts +70 -0
  219. package/extensions/lib/shell-spawn.ts +70 -6
  220. package/extensions/lib/shell-tool.ts +75 -25
  221. package/extensions/lib/side-call-cache.ts +124 -0
  222. package/extensions/lib/side-call-run.ts +96 -0
  223. package/extensions/lib/side-call-usage.ts +38 -0
  224. package/extensions/lib/side-call.ts +13 -1
  225. package/extensions/lib/single-flight.ts +18 -0
  226. package/extensions/lib/skill-body.ts +42 -0
  227. package/extensions/lib/skill-scan.ts +63 -24
  228. package/extensions/lib/spill-file.ts +140 -0
  229. package/extensions/lib/subagent-handback.ts +113 -0
  230. package/extensions/lib/system-role.ts +156 -0
  231. package/extensions/lib/terminal-text.ts +114 -0
  232. package/extensions/lib/text-width.ts +47 -16
  233. package/extensions/lib/timer-ops.ts +20 -0
  234. package/extensions/lib/tool-additions.ts +149 -0
  235. package/extensions/lib/tool-call-corruption.ts +134 -0
  236. package/extensions/lib/tool-path.ts +66 -0
  237. package/extensions/lib/tool-variants.ts +69 -0
  238. package/extensions/lib/tui-render.ts +255 -27
  239. package/extensions/lib/turn-budget-layout.ts +143 -0
  240. package/extensions/lib/update-check.mjs +15 -2
  241. package/extensions/lib/usage-bus.ts +1 -1
  242. package/extensions/lib/user-denial.ts +38 -0
  243. package/extensions/lib/workspace-channel.ts +16 -0
  244. package/extensions/lib/worktree-channel.ts +31 -3
  245. package/extensions/lib/worktree-isolation.ts +40 -10
  246. package/extensions/lsp/client.ts +8 -9
  247. package/extensions/lsp/index.ts +70 -4
  248. package/extensions/lsp/keep-alive.ts +1 -1
  249. package/extensions/lsp/trust.ts +119 -0
  250. package/extensions/lsp/watcher.ts +1 -1
  251. package/extensions/mcp/announce.ts +87 -5
  252. package/extensions/mcp/client.ts +161 -48
  253. package/extensions/mcp/config.ts +132 -8
  254. package/extensions/mcp/discovery.ts +19 -0
  255. package/extensions/mcp/index.ts +209 -71
  256. package/extensions/mcp/oauth/browser.ts +38 -17
  257. package/extensions/mcp/oauth/flow.ts +25 -17
  258. package/extensions/mcp/oauth/provider.ts +14 -8
  259. package/extensions/mcp/oauth/store.ts +34 -16
  260. package/extensions/mcp/panel/keys.ts +12 -11
  261. package/extensions/mcp/panel/render.ts +9 -6
  262. package/extensions/mcp/schema.ts +104 -5
  263. package/extensions/mcp/trust.ts +151 -67
  264. package/extensions/memory/entries.ts +23 -14
  265. package/extensions/memory/index.ts +134 -28
  266. package/extensions/memory/open-external.ts +33 -18
  267. package/extensions/memory/panel.ts +135 -26
  268. package/extensions/newer-model/index.ts +31 -0
  269. package/extensions/notebook/description.ts +18 -0
  270. package/extensions/notebook/index.ts +6 -5
  271. package/extensions/notebook/notebook.ts +1 -1
  272. package/extensions/permissions/index.ts +1300 -381
  273. package/extensions/permissions/matcher.ts +425 -74
  274. package/extensions/permissions/modes.ts +6 -4
  275. package/extensions/permissions/panel/host.ts +130 -0
  276. package/extensions/permissions/panel/keys.ts +48 -0
  277. package/extensions/permissions/panel/render.ts +450 -0
  278. package/extensions/permissions/panel/state.ts +550 -0
  279. package/extensions/permissions/powershell-cmdlets.ts +198 -0
  280. package/extensions/permissions/powershell-paths.ts +197 -0
  281. package/extensions/permissions/powershell-rules.ts +207 -280
  282. package/extensions/permissions/powershell-tree.ts +557 -0
  283. package/extensions/permissions/project-trust.ts +27 -11
  284. package/extensions/permissions/protected-paths.ts +14 -2
  285. package/extensions/permissions/session-grant.ts +6 -0
  286. package/extensions/permissions/settings.ts +166 -31
  287. package/extensions/permissions/subagent-gate.ts +25 -0
  288. package/extensions/permissions/workspace.ts +78 -0
  289. package/extensions/plan-mode/index.ts +156 -69
  290. package/extensions/plan-mode/reminder.ts +89 -21
  291. package/extensions/plan-mode/texts.ts +177 -0
  292. package/extensions/plan-mode/viewer.ts +92 -27
  293. package/extensions/plugins/counts.ts +3 -3
  294. package/extensions/plugins/index.ts +61 -46
  295. package/extensions/plugins/install/install.ts +16 -0
  296. package/extensions/plugins/marketplace/sync.ts +9 -3
  297. package/extensions/plugins/marketplace/types.ts +3 -1
  298. package/extensions/plugins/panel/keys.ts +21 -24
  299. package/extensions/plugins/panel/render.ts +12 -6
  300. package/extensions/plugins/shell-expand.ts +107 -0
  301. package/extensions/plugins/template.ts +32 -15
  302. package/extensions/powershell/child.ts +7 -0
  303. package/extensions/powershell/description.ts +38 -6
  304. package/extensions/powershell/guards.ts +1 -1
  305. package/extensions/powershell/index.ts +12 -7
  306. package/extensions/recap/index.ts +65 -60
  307. package/extensions/recap/prompt.ts +2 -2
  308. package/extensions/recap/scheduler.ts +3 -5
  309. package/extensions/search-tools/grep-merge.ts +39 -0
  310. package/extensions/search-tools/index.ts +0 -0
  311. package/extensions/send-now/chord.ts +62 -0
  312. package/extensions/send-now/index.ts +133 -0
  313. package/extensions/session-title/index.ts +3 -0
  314. package/extensions/session-title/title.ts +4 -1
  315. package/extensions/skill/code-review.ts +97 -0
  316. package/extensions/skill/index.ts +421 -101
  317. package/extensions/skill/invoke.ts +128 -18
  318. package/extensions/skill/listing.ts +133 -9
  319. package/extensions/skill/panel/keys.ts +15 -16
  320. package/extensions/skill/panel/render.ts +5 -2
  321. package/extensions/skill/panel/state.ts +12 -1
  322. package/extensions/subagents/agent-args.ts +39 -0
  323. package/extensions/subagents/agent-description.ts +132 -0
  324. package/extensions/subagents/agents.ts +116 -23
  325. package/extensions/subagents/announce.ts +69 -0
  326. package/extensions/subagents/default-model.ts +2 -3
  327. package/extensions/subagents/delegation-steer.ts +4 -4
  328. package/extensions/subagents/fork-cache.ts +76 -0
  329. package/extensions/subagents/guide-agent.ts +189 -0
  330. package/extensions/subagents/hand-back-review.ts +3 -0
  331. package/extensions/subagents/index.ts +1493 -824
  332. package/extensions/subagents/live-runs.ts +26 -6
  333. package/extensions/subagents/model-select.ts +38 -25
  334. package/extensions/subagents/outcome.ts +2 -2
  335. package/extensions/subagents/panel-keys.ts +73 -31
  336. package/extensions/subagents/panel-render.ts +120 -96
  337. package/extensions/subagents/panel-widget.ts +60 -89
  338. package/extensions/subagents/runner.ts +108 -80
  339. package/extensions/subagents/runs.ts +39 -5
  340. package/extensions/subagents/send-to-main-tool.ts +1 -1
  341. package/extensions/subagents/session-turns.ts +32 -8
  342. package/extensions/subagents/shell-panel.ts +36 -178
  343. package/extensions/subagents/tasks-dialog.ts +170 -0
  344. package/extensions/subagents/viewport-mirror.ts +95 -0
  345. package/extensions/subagents/worktree.ts +57 -13
  346. package/extensions/system-prompt/environment.ts +19 -19
  347. package/extensions/system-prompt/idle-turn.ts +65 -0
  348. package/extensions/system-prompt/index.ts +131 -47
  349. package/extensions/system-prompt/template.ts +24 -50
  350. package/extensions/system-prompt/tiers/common.ts +23 -64
  351. package/extensions/system-prompt/tiers/frontier.ts +32 -11
  352. package/extensions/system-prompt/tiers/low.ts +67 -45
  353. package/extensions/system-prompt/tiers/mid.ts +96 -38
  354. package/extensions/system-reminder/index.ts +175 -13
  355. package/extensions/tasks/descriptions.ts +190 -0
  356. package/extensions/tasks/index.ts +120 -41
  357. package/extensions/tasks/store.ts +83 -16
  358. package/extensions/tool-call-corruption/child.ts +7 -0
  359. package/extensions/tool-call-corruption/index.ts +237 -0
  360. package/extensions/tool-search/announce.ts +16 -0
  361. package/extensions/tool-search/index.ts +189 -41
  362. package/extensions/tool-style/index.ts +68 -10
  363. package/extensions/tool-style/write-description.ts +40 -0
  364. package/extensions/tool-style/write-render.ts +237 -0
  365. package/extensions/turn-duration/index.ts +7 -3
  366. package/extensions/turn-duration/line.ts +47 -4
  367. package/extensions/web/backends.ts +6 -9
  368. package/extensions/web/description.ts +50 -0
  369. package/extensions/web/index.ts +118 -53
  370. package/extensions/web/native-call.ts +15 -0
  371. package/extensions/web/native-results.ts +41 -0
  372. package/extensions/web-fetch/description.ts +42 -0
  373. package/extensions/web-fetch/extract.ts +53 -6
  374. package/extensions/web-fetch/index.ts +125 -76
  375. package/extensions/web-fetch/page-content.ts +19 -0
  376. package/extensions/web-fetch/response.ts +49 -0
  377. package/extensions/web-fetch/summarize.ts +9 -10
  378. package/extensions/workflow/agent-session.ts +105 -18
  379. package/extensions/workflow/args.ts +24 -0
  380. package/extensions/workflow/description.ts +67 -0
  381. package/extensions/workflow/globals.ts +17 -3
  382. package/extensions/workflow/index.ts +145 -162
  383. package/extensions/workflow/records.ts +1 -0
  384. package/extensions/workflow/run-manager.ts +29 -6
  385. package/extensions/workflow/saved-workflows.ts +3 -2
  386. package/extensions/workflow/types.ts +22 -1
  387. package/extensions/workflow/viewer.ts +54 -24
  388. package/extensions/workflow/widget.ts +20 -0
  389. package/extensions/worktree/descriptions.ts +91 -0
  390. package/extensions/worktree/guards.ts +316 -54
  391. package/extensions/worktree/index.ts +121 -39
  392. package/extensions/worktree/powershell-guards.ts +167 -0
  393. package/extensions/worktree/rewrite.ts +14 -16
  394. package/package.json +18 -7
  395. package/skills/artifact-design/SKILL.md +85 -0
  396. package/skills/code-review/SKILL.md +20 -20
  397. package/skills/code-review/cells/arguments-and-flags.md +19 -0
  398. package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
  399. package/skills/code-review/cells/high-8-inline.md +122 -0
  400. package/skills/code-review/cells/low-1-pass-max4.md +27 -0
  401. package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
  402. package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
  403. package/skills/code-review/cells/medium-3x5-verify.md +132 -0
  404. package/skills/code-review/cells/minimal-prompt.md +32 -0
  405. package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
  406. package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
  407. package/skills/fewer-permission-prompts/SKILL.md +1 -1
  408. package/skills/loop/SKILL.md +11 -0
  409. package/skills/security-review/SKILL.md +2 -5
  410. package/skills/simplify/SKILL.md +7 -5
  411. package/skills/workflow-authoring/SKILL.md +143 -0
  412. package/themes/onecode-light.json +1 -1
  413. package/themes/onecode.json +1 -1
  414. package/types/app.d.ts +58 -0
  415. package/types/community-packages.d.ts +26 -1
  416. package/extensions/auto-mode/recoverability.ts +0 -229
  417. package/extensions/lib/capability-index.ts +0 -466
  418. package/extensions/lib/model-facts.generated.json +0 -2675
  419. package/extensions/lib/model-facts.ts +0 -162
@@ -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,35 @@ 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 { freshDeliveryState, type LoopDeliveryState, readLoopFile, resolveLoopFire } from "./loop-fire.ts";
48
+ import { autonomousPreamble, loopSkillPrompt } from "./loop-skill.ts";
49
+ import {
50
+ CRON_CREATE_DESCRIPTION,
51
+ CRON_CREATE_PARAMETERS,
52
+ CRON_DELETE_DESCRIPTION,
53
+ CRON_DELETE_PARAMETERS,
54
+ CRON_LIST_DESCRIPTION,
55
+ CRON_LIST_PARAMETERS,
56
+ CronStore,
57
+ type CronFire,
58
+ type CronJob,
59
+ describeCadence,
60
+ formatCannotFire,
61
+ formatCreateResult,
62
+ formatDeleteResult,
63
+ formatJobList,
64
+ formatUnknownJob,
65
+ } from "./cron.ts";
39
66
  import {
40
67
  createTaskNotifier,
41
68
  monitorEndedSummary,
@@ -55,15 +82,34 @@ import {
55
82
  MONITOR_BATCH_MAX_CHARS,
56
83
  MONITOR_BATCH_IDLE_MS,
57
84
  type MonitorBatch,
85
+ MonitorLineSplitter,
58
86
  pushEvent,
59
87
  } from "./monitor-batch.ts";
60
88
  import { registerLocalCommand } from "../lib/local-command.ts";
61
-
62
- const OUTPUT_CAP = 30_000;
89
+ import { applyNoopFolds, decideFold, type FoldDetails, type FoldEntry, foldSuffix } from "./noop-fold.ts";
90
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
91
+ import { AGENT_CRON_CHANNEL, AGENT_CRON_FIRE_CHANNEL, type AgentCronFire, type AgentCronRequest, formatNotOwner } from "../lib/agent-cron.ts";
92
+ import { SKILL_BODY_CHANNEL, type SkillBodyQuery, SLASH_EXPAND_CHANNEL, type SlashExpandQuery } from "../lib/skill-body.ts";
93
+ import { BUNDLED_SKILLS_DIR } from "../lib/skill-scan.ts";
94
+ import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
95
+ import { join, resolve } from "node:path";
96
+ import { StringDecoder } from "node:string_decoder";
97
+ import { SESSION_WORK_CHANNEL, sessionBackgroundTask, sessionCron, type SessionWorkQuery } from "../lib/session-work.ts";
98
+ import { WORKTREE_CHANNEL, type WorktreeLocation } from "../lib/worktree-channel.ts";
99
+ import { queueNotice } from "../lib/notices.ts";
100
+
101
+ /** Claude Code's `task_output` limit (`TASK_MAX_OUTPUT_DEFAULT`). */
102
+ const TASK_OUTPUT_MAX_CHARS = 32_000;
63
103
  const STORED_OUTPUT_CAP = 200_000;
64
104
  const DEFAULT_MONITOR_TIMEOUT_MS = 300_000;
65
105
  const MAX_MONITOR_TIMEOUT_MS = 3_600_000;
66
106
  const MAX_BLOCK_TIMEOUT_MS = 600_000;
107
+ /** A finished monitor that saw nothing says so, never a blank a model reads as "still running" (bash's EMPTY_OUTPUT_MARKER). */
108
+ const MONITOR_EMPTY_OUTPUT = "(no output — the monitor received no events and nothing on stderr)";
109
+ /** Heads a monitor's output when the start is gone: past the in-memory cap with no spool file to hold it. */
110
+ 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`;
111
+ /** setTimeout's longest delay; a later fire re-arms when this one wakes. */
112
+ const MAX_TIMER_MS = 2 ** 31 - 1;
67
113
  function tail(text: string, cap: number): string {
68
114
  return text.length <= cap ? text : `… (earlier output truncated)\n${text.slice(-cap)}`;
69
115
  }
@@ -79,6 +125,58 @@ function tail(text: string, cap: number): string {
79
125
  */
80
126
  let pendingShutdownNotice: string | undefined;
81
127
 
128
+ /**
129
+ * `<sessionDir>/monitor/<taskId>/output.log`, beside background shells'
130
+ * `bash/<taskId>/`. A session-less run spools under the session's private
131
+ * results dir (`sessionResultsDir`), so its output is never kept only as the
132
+ * in-memory tail. Undefined when the folder cannot be made.
133
+ */
134
+ function monitorLogPath(ctx: ExtensionContext, taskId: string): string | undefined {
135
+ try {
136
+ const dir = join(sessionResultsDir(ctx), "monitor", taskId);
137
+ mkdirSync(dir, { recursive: true });
138
+ return join(dir, "output.log");
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * `task_output`'s body. Past `TASK_OUTPUT_MAX_CHARS` a task with a spool file
146
+ * gets Claude Code's clip, `[Truncated. Full output: <path>]` and the tail
147
+ * that fits, since the file holds the full text; a task without one (a
148
+ * subagent's report) is persisted whole to the session's tool-results
149
+ * folder, never cut (decisions/tools.md).
150
+ */
151
+ function taskOutputBody(task: BackgroundTask, ctx: ExtensionContext): string {
152
+ const output = task.output();
153
+ if (output.length <= TASK_OUTPUT_MAX_CHARS) return output;
154
+ if (task.logPath) {
155
+ const header = `[Truncated. Full output: ${task.logPath}]\n\n`;
156
+ return `${header}${output.slice(-(TASK_OUTPUT_MAX_CHARS - header.length))}`;
157
+ }
158
+ return persistIfLarge(output, { dir: sessionResultsDir(ctx), id: `task-output-${task.id}`, maxBytes: TASK_OUTPUT_MAX_CHARS });
159
+ }
160
+
161
+ /** A session entry as the no-op fold reads it (noop-fold.ts). */
162
+ function foldEntry(entry: { type: string; timestamp?: string; customType?: string; details?: unknown; message?: unknown }): FoldEntry {
163
+ if (entry.type === "compaction") return { kind: "compaction" };
164
+ if (entry.type === "custom_message") {
165
+ if (entry.customType === "wakeup") return { kind: "wakeup", timestamp: Date.parse(entry.timestamp ?? "") || 0, details: entry.details as FoldDetails | undefined };
166
+ return entry.customType === "cron" ? { kind: "fire" } : { kind: "other" };
167
+ }
168
+ if (entry.type !== "message") return { kind: "other" };
169
+ const message = entry.message as { role?: string; content?: unknown; stopReason?: string; toolCallId?: string };
170
+ if (message.role === "user") return { kind: "user" };
171
+ if (message.role === "toolResult") return { kind: "toolResult", toolCallId: message.toolCallId ?? "" };
172
+ if (message.role === "assistant") {
173
+ const blocks = Array.isArray(message.content) ? (message.content as Array<{ type?: string; id?: string; name?: string; arguments?: { noop?: unknown } }>) : [];
174
+ const toolCalls = blocks.filter((b) => b.type === "toolCall").map((b) => ({ id: b.id ?? "", name: b.name ?? "", noop: b.arguments?.noop }));
175
+ return { kind: "assistant", toolCalls, aborted: message.stopReason === "aborted" };
176
+ }
177
+ return { kind: "other" };
178
+ }
179
+
82
180
  export default function backgroundExtension(pi: ExtensionAPI) {
83
181
  // After session_shutdown the captured ctx throws on every access and the
84
182
  // notifier is inert; a monitor whose command ends later (its shell was
@@ -88,32 +186,78 @@ export default function backgroundExtension(pi: ExtensionAPI) {
88
186
  const alive = sessionAlive(pi);
89
187
  const registry = new BackgroundRegistry();
90
188
  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.
189
+ const cron = new CronStore();
190
+ const dynamicLoop = new DynamicLoop(cron);
191
+ // What the no-prompt /loop sentinels already delivered, per recipient (the
192
+ // main session, or a subagent by task id): each conversation has its own
193
+ // record. The main session's compaction resets its own (loop-fire.ts).
194
+ const MAIN_OWNER = "main";
195
+ const loopDelivery = new Map<string, LoopDeliveryState>();
196
+ const deliveryFor = (owner: string): LoopDeliveryState => {
197
+ let state = loopDelivery.get(owner);
198
+ if (!state) loopDelivery.set(owner, (state = freshDeliveryState()));
199
+ return state;
200
+ };
201
+ let sessionCwd = process.cwd();
202
+ // The main session's worktree while it is in one (enter_worktree), where its fires resolve.
203
+ let worktreeCwd: string | undefined;
204
+ pi.events.on(WORKTREE_CHANNEL, (data) => {
205
+ worktreeCwd = (data as WorktreeLocation | null)?.path;
206
+ });
207
+ let cronTimer: NodeJS.Timeout | undefined;
208
+ // True between agent_start and agent_settled, so a due cron job waits for the
209
+ // turn to end instead of injecting one mid-run. agent_settled, not agent_end:
210
+ // agent_end can precede an internal retry / auto-compaction / queued
211
+ // continuation, and a fire landing in that gap would inject a turn while the
212
+ // prior one is still about to resume.
100
213
  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
214
 
105
- pi.events.on(TASK_REGISTER_CHANNEL, (task) => registry.register(task as BackgroundTask));
215
+ pi.events.on(TASK_REGISTER_CHANNEL, (data) => {
216
+ const task = data as BackgroundTask;
217
+ registry.register(task);
218
+ // Shells and subagent runs count toward the widget's warning line too.
219
+ updateWidget();
220
+ task.finished?.then(updateWidget, updateWidget);
221
+ });
222
+ // A subagent's cron tools (lib/agent-cron.ts): its jobs, its view, its deletes.
223
+ pi.events.on(AGENT_CRON_CHANNEL, (data) => {
224
+ const request = data as AgentCronRequest;
225
+ if (request.op === "create") {
226
+ const created = cron.create({ cron: request.cron, prompt: request.prompt, recurring: request.recurring, agentId: request.agentId, cwd: request.cwd }, Date.now());
227
+ request.result = created.ok
228
+ ? { text: formatCreateResult(created.job), details: { jobId: created.job.id } }
229
+ : { text: created.error, isError: true };
230
+ if (created.ok) runCron();
231
+ } else if (request.op === "list") {
232
+ const jobs = cron.list().filter((job) => job.agentId === request.agentId);
233
+ request.result = { text: formatJobList(jobs), details: { jobCount: jobs.length } };
234
+ } else {
235
+ const job = cron.get(request.id);
236
+ if (!job) request.result = { text: formatUnknownJob(request.id), isError: true };
237
+ else if (job.agentId !== request.agentId) request.result = { text: formatNotOwner(request.id), isError: true };
238
+ else {
239
+ cron.delete(request.id);
240
+ runCron();
241
+ request.result = { text: formatDeleteResult(request.id) };
242
+ }
243
+ }
244
+ });
245
+ // The Stop hook's session_crons / background_tasks (lib/session-work.ts).
246
+ pi.events.on(SESSION_WORK_CHANNEL, (data) => {
247
+ const query = data as SessionWorkQuery;
248
+ query.crons.push(...cron.list().map(sessionCron));
249
+ query.tasks.push(...registry.running().map(sessionBackgroundTask));
250
+ });
106
251
 
107
252
  const updateWidget = () => {
108
253
  // liveUiCtx: a stale ctx (session replaced) reads as "no UI", never a throw;
109
254
  // session_shutdown also drops lastCtx, so a late repaint is a no-op.
110
255
  const live = liveUiCtx(lastCtx);
111
256
  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);
257
+ // Every running task and scheduled job, panel-owned ones included: the
258
+ // line is the warning that /clear, /reload or quitting stops them (switch-guard.ts).
259
+ const line = workWidgetLine(registry.running().length, cron.list().length);
260
+ live.ui.setWidget("cc-background", line ? [line] : undefined);
117
261
  };
118
262
 
119
263
  // The wire frames are model-facing; the transcript shows a compact headline
@@ -123,20 +267,88 @@ export default function backgroundExtension(pi: ExtensionAPI) {
123
267
  pi.registerMessageRenderer("task-notification", (message, { expanded }, theme) =>
124
268
  notificationComponent(theme, customMessageText(message.content), expanded),
125
269
  );
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),
270
+ // A notification that opens the session's first turn is a user message
271
+ // (lib/notifications.ts, "First turn of a session"); pi draws those as
272
+ // markdown, so the same headline replaces the frames here. Every notifier's
273
+ // messages pass through this one transformer.
274
+ pi.registerMarkdownTransformer((markdown, context) =>
275
+ context.messageType === "user" ? (notificationUserMarkdown(markdown) ?? markdown) : markdown,
134
276
  );
277
+ for (const customType of ["wakeup", "cron"]) {
278
+ // Claude Code: "Claude resuming /loop wakeup (…)" for a wakeup; the model here may not be Claude.
279
+ const label = customType === "wakeup" ? "Resuming /loop wakeup" : undefined;
280
+ pi.registerMessageRenderer(customType, (message, { expanded }, theme) => {
281
+ const fold = message.details as FoldDetails | undefined;
282
+ const suffix = fold?.noOpStreak ? foldSuffix(fold.noOpStreak, formatFireTime(fold.streakStartedAt ?? Date.now())) : "";
283
+ return scheduledTaskComponent(theme, customMessageText(message.content), (message as { timestamp?: number }).timestamp ?? Date.now(), expanded, label, suffix);
284
+ });
285
+ }
135
286
 
136
287
  // A monitor's end the model already read through task_output is withdrawn
137
288
  // like a shell's (lib/notifications.ts).
138
289
  const notify = createTaskNotifier(pi, { withdrawOnDelivery: true });
139
290
 
291
+ /**
292
+ * The text a fired prompt delivers, as Claude Code's queue would run it: a
293
+ * no-prompt /loop sentinel expands (loop-fire.ts), and a slash command that
294
+ * names a skill runs that skill (lib/skill-body.ts); anything else is the
295
+ * prompt verbatim. Both resolve in the job owner's directory: a subagent's
296
+ * own, or the main session's worktree while it is in one.
297
+ */
298
+ const fireText = (job: CronJob): string => {
299
+ const { prompt } = job;
300
+ const cwd = job.cwd ?? worktreeCwd ?? sessionCwd;
301
+ const resolved = resolveLoopFire(deliveryFor(job.agentId ?? MAIN_OWNER), prompt, cwd, autonomousPreamble());
302
+ if (resolved !== prompt || !prompt.startsWith("/")) return resolved;
303
+ const query: SlashExpandQuery = { text: prompt, cwd };
304
+ pi.events.emit(SLASH_EXPAND_CHANNEL, query);
305
+ return query.expanded ?? prompt;
306
+ };
307
+ /** Claude Code's no-op fold for the wakeup firing now (noop-fold.ts), read off the session branch. */
308
+ const foldDetails = (): FoldDetails => {
309
+ let entries: FoldEntry[];
310
+ try {
311
+ entries = (lastCtx?.sessionManager.getBranch() ?? []).map(foldEntry);
312
+ } catch {
313
+ return {};
314
+ }
315
+ const decision = decideFold(entries);
316
+ return decision.kind === "fold" ? { noOpStreak: decision.priorStreak + 1, streakStartedAt: decision.since } : {};
317
+ };
318
+ const fireCron = (fire: CronFire) => {
319
+ const { job, final } = fire;
320
+ if (job.agentId !== undefined) {
321
+ // A subagent's job goes to that agent, or is dropped once it has ended.
322
+ const delivery: AgentCronFire = { agentId: job.agentId, jobId: job.id, prompt: fireText(job) };
323
+ pi.events.emit(AGENT_CRON_FIRE_CHANNEL, delivery);
324
+ if (!delivery.delivered) {
325
+ // The agent has ended: its job and what it was sent go with it.
326
+ cron.delete(job.id);
327
+ loopDelivery.delete(job.agentId);
328
+ }
329
+ return;
330
+ }
331
+ const fold = job.source === "wakeup" ? foldDetails() : {};
332
+ if (job.source === "wakeup") dynamicLoop.inFlight = job.prompt;
333
+ notify(job.source === "wakeup" ? "wakeup" : "cron", fireText(job), { jobId: job.id, cron: job.cron, source: job.source, final, ...fold });
334
+ };
335
+ /** Fire every due job (only while idle), then arm the one timer to the next fire. */
336
+ const runCron = () => {
337
+ if (cronTimer) clearTimeout(cronTimer);
338
+ cronTimer = undefined;
339
+ // A busy agent re-runs this at agent_settled; a dead session never does.
340
+ if (!alive() || agentBusy) {
341
+ updateWidget();
342
+ return;
343
+ }
344
+ for (const fire of cron.takeDue(Date.now())) fireCron(fire);
345
+ updateWidget();
346
+ const next = cron.nextFireAt();
347
+ if (next === undefined) return;
348
+ cronTimer = setTimeout(runCron, Math.min(Math.max(0, next - Date.now()), MAX_TIMER_MS));
349
+ cronTimer.unref?.();
350
+ };
351
+
140
352
  pi.registerTool({
141
353
  name: "monitor",
142
354
  label: "Monitor",
@@ -180,6 +392,32 @@ export default function backgroundExtension(pi: ExtensionAPI) {
180
392
  const id = generateTaskId();
181
393
  let status: BackgroundTask["status"] = "running";
182
394
  let stored = "";
395
+ // The whole stream, spooled like a background shell's, so the batches'
396
+ // "task_output has the full stream" and task_output's clip header point
397
+ // at something true, and a one-shot result past the in-memory cap can
398
+ // still persist every line. Opened on the first output.
399
+ let log: WriteStream | undefined;
400
+ let spoolFailed = false;
401
+ let overflowed = false;
402
+ const record = (text: string) => {
403
+ if (stored.length + text.length > STORED_OUTPUT_CAP) overflowed = true;
404
+ stored = tail(`${stored}${text}`, STORED_OUTPUT_CAP);
405
+ if (!text || spoolFailed) return;
406
+ if (!log) {
407
+ const path = monitorLogPath(ctx, id);
408
+ if (!path) return;
409
+ log = createWriteStream(path, { flags: "a" });
410
+ log.on("error", () => {
411
+ // Best-effort, as bash's spool: the in-memory tail stays
412
+ // authoritative, and nothing names a missing or partial file as
413
+ // the full output any more.
414
+ spoolFailed = true;
415
+ task.logPath = undefined;
416
+ });
417
+ task.logPath = path;
418
+ }
419
+ log.write(text);
420
+ };
183
421
  let eventCount = 0;
184
422
  let pending: MonitorBatch = emptyBatch();
185
423
  let flushTimer: NodeJS.Timeout | undefined;
@@ -188,9 +426,16 @@ export default function backgroundExtension(pi: ExtensionAPI) {
188
426
  finish = resolve;
189
427
  });
190
428
 
191
- const flush = () => {
429
+ const flush = (force = false) => {
192
430
  flushTimer = undefined;
193
431
  if (batchSize(pending) === 0) return;
432
+ if (!force && notify.holding()) {
433
+ // Held after an interrupt: one bounded batch accumulates (its
434
+ // overflow counted) instead of one held message per flush, and
435
+ // goes out once the next turn starts.
436
+ flushTimer = setTimeout(() => flush(), MONITOR_BATCH_IDLE_MS);
437
+ return;
438
+ }
194
439
  const batch = pending;
195
440
  pending = emptyBatch();
196
441
  // CC's mid-run monitor batch: no status, the lines in `<event>`.
@@ -201,48 +446,71 @@ export default function backgroundExtension(pi: ExtensionAPI) {
201
446
  );
202
447
  };
203
448
 
449
+ // Set by stop(): what the command prints while it is torn down (a shell
450
+ // running the rest of `sleep 30; echo done` before the SIGKILL grace ends)
451
+ // is not an event, so the count says what happened before the stop.
452
+ let stopRequested = false;
204
453
  const onEvent = (line: string) => {
454
+ if (stopRequested) return;
205
455
  eventCount++;
206
- stored = tail(`${stored}${line}\n`, STORED_OUTPUT_CAP);
456
+ record(`${line}\n`);
207
457
  if (oneShot) return; // collected into the tool result instead
208
458
  pushEvent(pending, line);
209
459
  // Bounded batches, and a wider window mid-turn so a chatty stream
210
460
  // coalesces instead of steering one notification per second
211
461
  // (monitor-batch.ts).
212
- flushTimer ??= setTimeout(flush, agentBusy ? MONITOR_BATCH_BUSY_MS : MONITOR_BATCH_IDLE_MS);
462
+ flushTimer ??= setTimeout(() => flush(), agentBusy ? MONITOR_BATCH_BUSY_MS : MONITOR_BATCH_IDLE_MS);
213
463
  };
214
464
 
215
465
  const end = (finalStatus: BackgroundTask["status"], note?: string) => {
216
466
  if (status !== "running") return;
217
467
  status = finalStatus;
218
- task.status = finalStatus;
219
- task.finishedAt = Date.now();
220
468
  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
- );
469
+ const complete = () => {
470
+ if (task.status !== "running") return;
471
+ // Terminal status and its notification promise readable final output.
472
+ task.status = finalStatus;
473
+ task.finishedAt = Date.now();
474
+ finish();
475
+ // After shutdown or inside a one-shot run, report through `finished` alone.
476
+ if (!alive() || oneShot) return;
477
+ flush(true);
478
+ updateWidget();
479
+ // CC's monitor end: the recent tail rides <event> under the ended summary.
480
+ const ccStatus = taskStatusOf(finalStatus);
481
+ const recent = tail(stored, MONITOR_BATCH_MAX_CHARS).trim();
482
+ notify(
483
+ "task-notification",
484
+ taskNotification({
485
+ kind: "monitor",
486
+ taskId: id,
487
+ toolUseId: toolCallId,
488
+ status: ccStatus,
489
+ summary: monitorEndedSummary(params.description, ccStatus, eventCount > 0, note),
490
+ result: recent || undefined,
491
+ }),
492
+ { taskId: id, status: finalStatus },
493
+ );
494
+ };
495
+ if (log && !log.closed) {
496
+ // `close` follows a clean end and an error alike. A stuck stream
497
+ // must not hold a blocking tool forever or advertise a partial file.
498
+ const fallback = setTimeout(() => {
499
+ spoolFailed = true;
500
+ task.logPath = undefined;
501
+ log?.destroy();
502
+ complete();
503
+ }, 1_000);
504
+ log.once("close", () => {
505
+ clearTimeout(fallback);
506
+ complete();
507
+ });
508
+ log.end();
509
+ } else {
510
+ complete();
511
+ }
243
512
  };
244
513
 
245
- let stopRequested = false;
246
514
  let stop: () => void;
247
515
  if (params.command) {
248
516
  // Own process group: stop() must end the COMMAND, not just the `$SHELL -c`
@@ -263,27 +531,32 @@ export default function backgroundExtension(pi: ExtensionAPI) {
263
531
  ...detachedSpawnOptions(),
264
532
  stdio: ["ignore", "pipe", "pipe"],
265
533
  });
266
- let buffer = "";
534
+ rememberProcessGroup(child);
535
+ const lines = new MonitorLineSplitter();
536
+ // Streaming decoders: a UTF-8 character split across reads stays whole.
537
+ const stdoutText = new StringDecoder("utf8");
538
+ const stderrText = new StringDecoder("utf8");
267
539
  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
- }
540
+ for (const line of lines.push(stdoutText.write(chunk))) onEvent(line);
275
541
  });
276
542
  child.stderr?.on("data", (chunk: Buffer) => {
277
- stored = tail(`${stored}${chunk.toString()}`, STORED_OUTPUT_CAP);
543
+ record(stderrText.write(chunk));
278
544
  });
279
545
  // Exit plus a short stdio grace, not `close` (lib/process-tree.ts).
546
+ // The last line may have no newline; it is an event all the same.
547
+ const drain = () => {
548
+ for (const line of [...lines.push(stdoutText.end()), ...lines.end()]) onEvent(line);
549
+ record(stderrText.end());
550
+ };
280
551
  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),
552
+ ({ code }) => {
553
+ drain();
554
+ end(stopRequested ? "stopped" : code === 0 ? "completed" : "failed", code !== null && code !== 0 ? `exit code ${code}` : undefined);
555
+ },
556
+ (error: Error) => {
557
+ drain();
558
+ end("failed", error.message);
559
+ },
287
560
  );
288
561
  stop = () => {
289
562
  stopRequested = true;
@@ -322,7 +595,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
322
595
  description: params.description,
323
596
  status,
324
597
  startedAt: Date.now(),
325
- output: () => stored,
598
+ output: () => (overflowed && !task.logPath ? `${SPOOL_LOST_NOTE}${stored}` : stored || (task.status === "running" ? "" : MONITOR_EMPTY_OUTPUT)),
326
599
  stop,
327
600
  finished,
328
601
  };
@@ -349,7 +622,11 @@ export default function backgroundExtension(pi: ExtensionAPI) {
349
622
  } finally {
350
623
  unhook();
351
624
  }
352
- const output = persistIfLarge(stored.trim() || "(no events)", { dir: sessionResultsDir(ctx), id: `monitor-${id}` });
625
+ // Past the in-memory cap the spool holds every line: point at it rather
626
+ // than persist the tail. Without one, say what was lost.
627
+ const spooled = overflowed && task.logPath ? persistedFilePreview(task.logPath) : undefined;
628
+ const kept = overflowed && !spooled ? `${SPOOL_LOST_NOTE}${stored.trim()}` : stored.trim() || "(no events)";
629
+ const output = spooled ?? persistIfLarge(kept, { dir: sessionResultsDir(ctx), id: `monitor-${id}` });
353
630
  const finalStatus = task.status;
354
631
  let how: string = finalStatus;
355
632
  if (finalStatus === "stopped") {
@@ -407,22 +684,26 @@ export default function backgroundExtension(pi: ExtensionAPI) {
407
684
 
408
685
  if (params.block !== false && task.status === "running") {
409
686
  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
- ]);
687
+ let timer: ReturnType<typeof setTimeout> | undefined;
688
+ let unhook = () => {};
689
+ try {
690
+ await Promise.race([
691
+ task.finished,
692
+ new Promise<void>((resolve) => {
693
+ // The tool awaits this deadline: keep it referenced in one-shot runs.
694
+ timer = setTimeout(resolve, timeoutMs);
695
+ unhook = whenAborted(signal, resolve);
696
+ }),
697
+ ]);
698
+ } finally {
699
+ clearTimeout(timer);
700
+ unhook();
701
+ }
421
702
  }
422
703
 
423
704
  updateWidget();
424
705
  const header = formatTaskLine(task);
425
- const body = tail(task.output(), OUTPUT_CAP) || "(no output yet)";
706
+ const body = taskOutputBody(task, ctx) || "(no output yet)";
426
707
  // The model now holds a finished task's output: a completion notification
427
708
  // still waiting to go out for it is redundant (lib/notifications.ts).
428
709
  if (task.status !== "running") pi.events.emit(TASK_OUTPUT_DELIVERED_CHANNEL, { taskId: task.id } satisfies TaskOutputDelivered);
@@ -437,9 +718,12 @@ export default function backgroundExtension(pi: ExtensionAPI) {
437
718
  name: "task_stop",
438
719
  label: "Stop Task",
439
720
  ...ccToolRenderers("Stop Task"),
440
- description: "Stop a running background task (monitor, background subagent, or background bash) by task id.",
721
+ // Claude Code's TaskStop text without its agent-team and agent-name clauses:
722
+ // a task is stopped by its id here.
723
+ description:
724
+ "\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
725
  parameters: Type.Object({
442
- task_id: Type.String({ description: "The id of the background task to stop" }),
726
+ task_id: Type.String({ description: "The ID of the background task to stop." }),
443
727
  }),
444
728
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
445
729
  lastCtx = ctx;
@@ -463,7 +747,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
463
747
  {
464
748
  type: "text",
465
749
  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).`
750
+ ? `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
751
  : `Stop requested for ${task.id} (${task.description}).`,
468
752
  },
469
753
  ],
@@ -478,61 +762,91 @@ export default function backgroundExtension(pi: ExtensionAPI) {
478
762
  ...ccToolRenderers<{ delaySeconds?: number; stop?: boolean }>("Schedule Wakeup", {
479
763
  title: (a) => (a?.stop ? "stop" : a?.delaySeconds !== undefined ? `${a.delaySeconds}s` : undefined),
480
764
  }),
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.",
765
+ description: SCHEDULE_WAKEUP_DESCRIPTION,
483
766
  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" })),
767
+ delaySeconds: Type.Optional(Type.Number({ description: SCHEDULE_WAKEUP_PARAMS.delaySeconds })),
768
+ reason: Type.Optional(Type.String({ description: SCHEDULE_WAKEUP_PARAMS.reason })),
769
+ prompt: Type.Optional(Type.String({ description: SCHEDULE_WAKEUP_PARAMS.prompt })),
770
+ stop: Type.Optional(Type.Boolean({ description: SCHEDULE_WAKEUP_PARAMS.stop })),
771
+ noop: Type.Optional(Type.Boolean({ description: SCHEDULE_WAKEUP_PARAMS.noop })),
489
772
  }),
490
773
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
491
774
  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: {} };
775
+ const fail = (text: string) => ({ content: [{ type: "text" as const, text }], details: {}, isError: true });
776
+ if (params.stop === true) {
777
+ const cancelled = dynamicLoop.stop();
778
+ runCron();
779
+ return { content: [{ type: "text", text: formatStopped(cancelled) }], details: { stopped: true, cancelledWakeups: cancelled } };
501
780
  }
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
- };
515
- }
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 } };
781
+ if (params.delaySeconds === undefined || params.reason === undefined) return fail(WAKEUP_ERRORS.delayAndReason);
782
+ if (params.prompt === undefined) return fail(WAKEUP_ERRORS.prompt);
783
+ if (params.noop === undefined) return fail(WAKEUP_ERRORS.noop);
784
+ const now = Date.now();
785
+ const scheduled = dynamicLoop.schedule(params.delaySeconds, params.prompt, now);
786
+ runCron();
787
+ if (!scheduled) return { content: [{ type: "text", text: AGED_OUT_RESULT }], details: { scheduledFor: 0 } };
788
+ const text = formatScheduled(scheduled, now);
789
+ return {
790
+ content: [{ type: "text", text: sessionOutlivesTurn(ctx.mode) ? text : `${text} ${formatCannotFire()}` }],
791
+ details: { ...scheduled, reason: params.reason, noop: params.noop },
792
+ };
793
+ },
794
+ });
795
+
796
+ pi.registerTool({
797
+ name: "cron_create",
798
+ label: "Cron Create",
799
+ ...ccToolRenderers<{ cron?: string }>("Cron Create", { title: (a) => a?.cron }),
800
+ description: CRON_CREATE_DESCRIPTION,
801
+ parameters: CRON_CREATE_PARAMETERS,
802
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
803
+ lastCtx = ctx;
804
+ const created = cron.create({ cron: params.cron, prompt: params.prompt, recurring: params.recurring }, Date.now());
805
+ if (!created.ok) return { content: [{ type: "text", text: created.error }], details: {}, isError: true };
806
+ runCron();
807
+ const text = formatCreateResult(created.job);
808
+ return {
809
+ content: [{ type: "text", text: sessionOutlivesTurn(ctx.mode) ? text : `${text} ${formatCannotFire()}` }],
810
+ details: { jobId: created.job.id, cron: created.job.cron, recurring: created.job.recurring, nextFireAt: created.job.nextFireAt },
811
+ };
528
812
  },
529
813
  });
530
814
 
815
+ pi.registerTool({
816
+ name: "cron_list",
817
+ label: "Cron List",
818
+ ...ccToolRenderers("Cron List"),
819
+ description: CRON_LIST_DESCRIPTION,
820
+ parameters: CRON_LIST_PARAMETERS,
821
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
822
+ lastCtx = ctx;
823
+ const jobs = cron.list();
824
+ return { content: [{ type: "text", text: formatJobList(jobs) }], details: { count: jobs.length } };
825
+ },
826
+ });
827
+
828
+ pi.registerTool({
829
+ name: "cron_delete",
830
+ label: "Cron Delete",
831
+ ...ccToolRenderers<{ id?: string }>("Cron Delete", { title: (a) => a?.id }),
832
+ description: CRON_DELETE_DESCRIPTION,
833
+ parameters: CRON_DELETE_PARAMETERS,
834
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
835
+ lastCtx = ctx;
836
+ if (!cron.delete(params.id)) return { content: [{ type: "text", text: formatUnknownJob(params.id) }], details: {}, isError: true };
837
+ runCron();
838
+ return { content: [{ type: "text", text: formatDeleteResult(params.id) }], details: { jobId: params.id } };
839
+ },
840
+ });
841
+
842
+ // schedule_wakeup stays eager, as Claude Code's ScheduleWakeup is.
531
843
  for (const [name, keywords] of Object.entries({
532
844
  monitor: ["monitor", "watch", "background", "tail", "events", "stream", "websocket"],
533
845
  task_output: ["task", "background", "output", "status", "wait"],
534
846
  task_stop: ["task", "background", "stop", "kill", "cancel"],
535
- schedule_wakeup: ["wakeup", "loop", "schedule", "timer", "recurring", "later"],
847
+ cron_create: ["cron", "schedule", "recurring", "remind", "timer", "every", "later"],
848
+ cron_list: ["cron", "schedule", "list", "jobs", "scheduled"],
849
+ cron_delete: ["cron", "schedule", "cancel", "delete", "stop"],
536
850
  })) {
537
851
  pi.events.emit(DEFER_CHANNEL, { name, keywords });
538
852
  }
@@ -545,100 +859,23 @@ export default function backgroundExtension(pi: ExtensionAPI) {
545
859
  },
546
860
  });
547
861
 
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;
558
- }
559
- };
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
- },
862
+ // `/loop` is a bundled skill whose body depends on its arguments; build it
863
+ // for the skill extension (lib/skill-body.ts). Only our own SKILL.md: a
864
+ // project skill named "loop" keeps its file's body.
865
+ const loopSkillPath = join(BUNDLED_SKILLS_DIR, "loop", "SKILL.md");
866
+ pi.events.on(SKILL_BODY_CHANNEL, (data) => {
867
+ const query = data as SkillBodyQuery;
868
+ if (query.skill !== "loop" || resolve(query.path) !== loopSkillPath) return;
869
+ query.body = loopSkillPrompt(query.args, readLoopFile(query.cwd));
634
870
  });
635
871
 
636
872
  pi.on("session_start", (_event, ctx) => {
637
873
  lastCtx = ctx;
874
+ sessionCwd = ctx.cwd;
638
875
  if (pendingShutdownNotice) {
639
876
  const notice = pendingShutdownNotice;
640
877
  pendingShutdownNotice = undefined;
641
- if (ctx.hasUI) ctx.ui.notify(notice, "info");
878
+ if (ctx.hasUI) queueNotice(pi.events, ctx, "info", notice);
642
879
  }
643
880
  });
644
881
  pi.on("agent_start", () => {
@@ -646,22 +883,63 @@ export default function backgroundExtension(pi: ExtensionAPI) {
646
883
  });
647
884
  pi.on("agent_settled", () => {
648
885
  agentBusy = false;
886
+ // Claude Code's keepalive: a wakeup's turn that scheduled no next one gets one fallback.
887
+ dynamicLoop.settle(Date.now());
888
+ runCron();
889
+ });
890
+ // Folded no-op ticks leave the model's context (noop-fold.ts); the note stands in for them.
891
+ pi.on("context", (event) => {
892
+ const messages = applyNoopFolds(event.messages as Array<AgentMessage & { role: string }>, (text, timestamp) => ({ role: "user", content: [{ type: "text", text }], timestamp }) as AgentMessage & { role: string });
893
+ return messages ? { messages } : undefined;
894
+ });
895
+ // The first fire after a compaction resends the full loop instructions, as in Claude Code.
896
+ pi.on("session_compact", () => {
897
+ loopDelivery.delete(MAIN_OWNER);
649
898
  });
650
899
 
900
+ // A switch stops everything below, so ask first. pi can cancel a switch but
901
+ // not a quit; the widget line covers quitting (switch-guard.ts).
902
+ const confirmSwitch = async (ctx: ExtensionContext, action: string): Promise<{ cancel: true } | undefined> => {
903
+ if (!ctx.hasUI) return undefined;
904
+ const title = switchWarning(
905
+ registry.running().map((t) => ({ id: t.id, label: t.description })),
906
+ cron.list().map((j) => ({ id: j.id, label: describeCadence(j.cron) })),
907
+ action,
908
+ );
909
+ if (!title) return undefined;
910
+ const choice = await ctx.ui.select(title, [SWITCH_CANCEL, SWITCH_STOP]);
911
+ return choice === SWITCH_STOP ? undefined : { cancel: true };
912
+ };
913
+ pi.on("session_before_switch", (event, ctx) =>
914
+ confirmSwitch(ctx, event.reason === "resume" ? "Resuming another session" : "Starting a new session"),
915
+ );
916
+ pi.on("session_before_fork", (_event, ctx) => confirmSwitch(ctx, "Forking the session"));
917
+
651
918
  pi.on("session_shutdown", (event) => {
652
919
  // Fires on /clear, /new, /resume and /reload too (findings §8). Everything
653
920
  // running dies with this instance — the replacement instance has no handle
654
921
  // on it — and the replacement is told (see pendingShutdownNotice); a quit
655
922
  // needs no note. A reload keeps the conversation, so its note says so.
656
923
  const running = registry.running();
657
- if (running.length > 0 && event.reason !== "quit") {
658
- const names = running.map((t) => `${t.id} (${t.description})`).join(", ");
924
+ const jobs = cron.list();
925
+ if ((running.length > 0 || jobs.length > 0) && event.reason !== "quit") {
659
926
  const when = event.reason === "reload" ? "on reload" : "with the previous session";
660
- pendingShutdownNotice = `Stopped ${running.length} background task${running.length === 1 ? "" : "s"} ${when}: ${names}.`;
927
+ const notices: string[] = [];
928
+ if (running.length > 0) {
929
+ const names = running.map((t) => `${t.id} (${t.description})`).join(", ");
930
+ notices.push(`Stopped ${running.length} background task${running.length === 1 ? "" : "s"} ${when}: ${names}.`);
931
+ }
932
+ if (jobs.length > 0) {
933
+ const names = jobs.map((j) => `${j.id} (${describeCadence(j.cron)})`).join(", ");
934
+ notices.push(`Cancelled ${jobs.length} scheduled job${jobs.length === 1 ? "" : "s"} ${when}: ${names}.`);
935
+ }
936
+ pendingShutdownNotice = notices.join(" ");
661
937
  }
662
938
  registry.stopAll();
663
- clearWakeup();
664
- clearLoop();
939
+ cron.clear();
940
+ dynamicLoop.clear();
941
+ if (cronTimer) clearTimeout(cronTimer);
942
+ cronTimer = undefined;
665
943
  lastCtx = undefined;
666
944
  });
667
945
  }