one-code-extension 0.1.3 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -5,15 +5,19 @@
5
5
  * Owns the BackgroundRegistry. Other extensions (subagents) register their
6
6
  * long-running work over TASK_REGISTER_CHANNEL at runtime, so task_output and
7
7
  * task_stop address every background task in the session regardless of which
8
- * extension started it. Events and completions are delivered as follow-up
9
- * messages framed as system notifications, never as user input.
8
+ * extension started it. Events and completions are delivered as steered
9
+ * system notifications (lib/notifications.ts), never as user input.
10
10
  */
11
11
 
12
12
  import { spawn } from "node:child_process";
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { Type } from "typebox";
15
+ import { whenAborted } from "../lib/abort.ts";
15
16
  import { DEFER_CHANNEL } from "../lib/deferred.ts";
16
- import { ccToolRenderers, customMessageText, notificationComponent } from "../lib/tui-render.ts";
17
+ import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
18
+ import { detachedSpawnOptions, KILL_GRACE_MS, stopProcessTree } from "../lib/process-tree.ts";
19
+ import { sessionAlive } from "../lib/session-lifecycle.ts";
20
+ import { ccToolRenderers, customMessageText, liveUiCtx, notificationComponent } from "../lib/tui-render.ts";
17
21
  import {
18
22
  type BackgroundTask,
19
23
  BackgroundRegistry,
@@ -21,55 +25,101 @@ import {
21
25
  generateTaskId,
22
26
  TASK_REGISTER_CHANNEL,
23
27
  } from "./registry.ts";
24
- import { buildWakeupMessage, clampDelaySeconds, describeSchedule } from "./wakeup.ts";
25
- import { systemNotification } from "../lib/notifications.ts";
28
+ import {
29
+ buildDynamicLoopPrompt,
30
+ buildLoopMessage,
31
+ buildWakeupMessage,
32
+ clampDelaySeconds,
33
+ describeSchedule,
34
+ MAX_DELAY_SECONDS,
35
+ MIN_DELAY_SECONDS,
36
+ parseLoopArgs,
37
+ } from "./wakeup.ts";
38
+ import { createTaskNotifier, oneShotNote, sessionOutlivesTurn, systemNotification } from "../lib/notifications.ts";
39
+ import {
40
+ batchSize,
41
+ emptyBatch,
42
+ formatMonitorBatch,
43
+ MONITOR_BATCH_BUSY_MS,
44
+ MONITOR_BATCH_IDLE_MS,
45
+ type MonitorBatch,
46
+ pushEvent,
47
+ } from "./monitor-batch.ts";
26
48
 
27
49
  const OUTPUT_CAP = 30_000;
28
50
  const STORED_OUTPUT_CAP = 200_000;
29
- const EVENT_BATCH_MS = 1000;
30
51
  const DEFAULT_MONITOR_TIMEOUT_MS = 300_000;
31
52
  const MAX_MONITOR_TIMEOUT_MS = 3_600_000;
32
53
  const MAX_BLOCK_TIMEOUT_MS = 600_000;
33
-
34
54
  function tail(text: string, cap: number): string {
35
55
  return text.length <= cap ? text : `… (earlier output truncated)\n${text.slice(-cap)}`;
36
56
  }
37
57
 
58
+ /**
59
+ * `/clear`, `/new` and `/resume` stop every background task with the session
60
+ * they belong to (the notifier that would report them is inert by then, and
61
+ * the new session has no handle on them). Say so, once, in the NEW session:
62
+ * the old session's UI is torn down right after `session_shutdown`, so a
63
+ * notice printed there is lost. Module scope on purpose — jiti keeps this
64
+ * module across the factory re-run that builds the new instance, which is the
65
+ * only thing that survives the swap (LIFECYCLE-REVIEW-2026-09-06 L3).
66
+ */
67
+ let pendingShutdownNotice: string | undefined;
68
+
38
69
  export default function backgroundExtension(pi: ExtensionAPI) {
70
+ // After session_shutdown the captured ctx throws on every access and the
71
+ // notifier is inert; a monitor whose command ends later (its shell was
72
+ // killed, the orphaned command finished) must repaint nothing and say
73
+ // nothing — before this guard, the widget repaint from the child's `close`
74
+ // callback took the whole process down (LIFECYCLE-REVIEW-2026-09-06 H1).
75
+ const alive = sessionAlive(pi);
39
76
  const registry = new BackgroundRegistry();
40
77
  let lastCtx: ExtensionContext | undefined;
41
78
  let wakeup: { timer: NodeJS.Timeout; prompt: string; reason: string } | undefined;
79
+ // Fixed-interval /loop (harness-driven, auto-re-arming — distinct from the
80
+ // model-driven `wakeup` used by dynamic /loop). One at a time, like wakeup.
81
+ let loop: { timer: NodeJS.Timeout; intervalSeconds: number; task: string } | undefined;
82
+ // True between agent_start and agent_settled, so a fixed-interval tick can skip
83
+ // (not queue) while the previous tick's turn is still running — no backlog.
84
+ // agent_settled, not agent_end: agent_end can precede an internal retry /
85
+ // auto-compaction / queued continuation, and a tick landing in that gap would
86
+ // inject a turn while the prior one is still about to resume.
87
+ let agentBusy = false;
88
+ // Set when a dynamic /loop force-activated schedule_wakeup, so /loop stop can
89
+ // restore the lean tool surface it changed.
90
+ let activatedWakeupTool = false;
42
91
 
43
92
  pi.events.on(TASK_REGISTER_CHANNEL, (task) => registry.register(task as BackgroundTask));
44
93
 
45
94
  const updateWidget = () => {
46
- if (!lastCtx?.hasUI) return;
47
- const running = registry.running().length;
48
- lastCtx.ui.setWidget("cc-background", running > 0 ? [` background tasks: ${running} running`] : undefined);
95
+ // liveUiCtx: a stale ctx (session replaced) reads as "no UI", never a throw;
96
+ // session_shutdown also drops lastCtx, so a late repaint is a no-op.
97
+ const live = liveUiCtx(lastCtx);
98
+ if (!live) return;
99
+ // A task whose producer gives it first-class panel UI (bash shells and
100
+ // subagent runs in the subagents panel) is excluded, or this line would
101
+ // stay permanently lit next to the panel already showing the same work.
102
+ const running = registry.running().filter((t) => !t.ownUI).length;
103
+ live.ui.setWidget("cc-background", running > 0 ? [` background tasks: ${running} running`] : undefined);
49
104
  };
50
105
 
51
106
  // Harness-injected notifications carry anti-confabulation framing for the
52
107
  // model; the transcript shows a compact headline instead (ctrl+o expands).
53
108
  // One registration covers every emitter of the type (bash uses it too).
54
- for (const customType of ["task-notification", "wakeup"]) {
109
+ for (const customType of ["task-notification", "wakeup", "loop"]) {
55
110
  pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
56
111
  notificationComponent(theme, customMessageText(message.content), expanded),
57
112
  );
58
113
  }
59
114
 
60
- const notify = (customType: string, text: string, details: Record<string, unknown>) => {
61
- pi.sendMessage(
62
- { customType, content: [{ type: "text", text }], display: true, details },
63
- { deliverAs: "followUp", triggerTurn: true },
64
- );
65
- };
115
+ const notify = createTaskNotifier(pi);
66
116
 
67
117
  pi.registerTool({
68
118
  name: "monitor",
69
119
  label: "Monitor",
70
120
  ...ccToolRenderers("Monitor"),
71
121
  description:
72
- "Start a background monitor that streams events from a long-running command. Each stdout line becomes a notification delivered to the conversation while you keep working; the command exiting ends the watch. Use for 'tell me every time X happens' (tail -f, polling loops); for a single completion signal prefer a subagent or a blocking command. Returns a task id — stop with task_stop, inspect with task_output. Alternatively pass `ws` to watch a WebSocket (each text frame is an event).",
122
+ "Start a background monitor that streams events from a long-running command. Each stdout line becomes a notification delivered to the conversation while you keep working; the command exiting ends the watch. Use for 'tell me every time X happens' (tail -f, an until-loop that polls a condition); for a single command's completion prefer bash with run_in_background: true — its completion notification arrives on its own. Returns a task id — stop with task_stop, inspect with task_output (both deferred — load them with tool_search). Alternatively pass `ws` to watch a WebSocket (each text frame is an event).",
73
123
  parameters: Type.Object({
74
124
  command: Type.Optional(Type.String({ description: "Shell command; each stdout line is an event, exit ends the watch" })),
75
125
  description: Type.String({ description: "Short description of what is being monitored (shown in notifications)" }),
@@ -87,7 +137,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
87
137
  ),
88
138
  ),
89
139
  }),
90
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
140
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
91
141
  lastCtx = ctx;
92
142
  if (Boolean(params.command) === Boolean(params.ws)) {
93
143
  return {
@@ -97,11 +147,18 @@ export default function backgroundExtension(pi: ExtensionAPI) {
97
147
  };
98
148
  }
99
149
 
150
+ // One-shot modes (`-p` / `--mode json`) exit when the turn settles: the
151
+ // notifier goes inert, shutdown kills the monitor, and nothing it saw
152
+ // reaches anyone — the tool result had promised "events arrive as
153
+ // notifications" (LIFECYCLE-REVIEW M3, measured). Run it to its end
154
+ // there (exit or deadline) and return the collected events in the
155
+ // result — the shape bash and Agent use in these modes.
156
+ const oneShot = !sessionOutlivesTurn(ctx.mode);
100
157
  const id = generateTaskId();
101
158
  let status: BackgroundTask["status"] = "running";
102
159
  let stored = "";
103
160
  let eventCount = 0;
104
- let pending: string[] = [];
161
+ let pending: MonitorBatch = emptyBatch();
105
162
  let flushTimer: NodeJS.Timeout | undefined;
106
163
  let finish!: () => void;
107
164
  const finished = new Promise<void>((resolve) => {
@@ -110,21 +167,24 @@ export default function backgroundExtension(pi: ExtensionAPI) {
110
167
 
111
168
  const flush = () => {
112
169
  flushTimer = undefined;
113
- if (pending.length === 0) return;
170
+ if (batchSize(pending) === 0) return;
114
171
  const batch = pending;
115
- pending = [];
116
- notify(
117
- "task-notification",
118
- systemNotification(`Monitor ${id} (${params.description}) emitted ${batch.length} event(s):\n${batch.join("\n")}`),
119
- { taskId: id, events: batch.length },
120
- );
172
+ pending = emptyBatch();
173
+ notify("task-notification", systemNotification(formatMonitorBatch(id, params.description, batch)), {
174
+ taskId: id,
175
+ events: batchSize(batch),
176
+ });
121
177
  };
122
178
 
123
179
  const onEvent = (line: string) => {
124
180
  eventCount++;
125
181
  stored = tail(`${stored}${line}\n`, STORED_OUTPUT_CAP);
126
- pending.push(line);
127
- flushTimer ??= setTimeout(flush, EVENT_BATCH_MS);
182
+ if (oneShot) return; // collected into the tool result instead
183
+ pushEvent(pending, line);
184
+ // Bounded batches, and a wider window mid-turn so a chatty stream
185
+ // coalesces instead of steering one notification per second
186
+ // (monitor-batch.ts).
187
+ flushTimer ??= setTimeout(flush, agentBusy ? MONITOR_BATCH_BUSY_MS : MONITOR_BATCH_IDLE_MS);
128
188
  };
129
189
 
130
190
  const end = (finalStatus: BackgroundTask["status"], note?: string) => {
@@ -133,8 +193,12 @@ export default function backgroundExtension(pi: ExtensionAPI) {
133
193
  task.status = finalStatus;
134
194
  task.finishedAt = Date.now();
135
195
  if (flushTimer) clearTimeout(flushTimer);
136
- flush();
137
196
  finish();
197
+ // Past this point everything touches the session: a monitor ending
198
+ // after shutdown (H1) or inside a one-shot run reports through
199
+ // `finished` alone.
200
+ if (!alive() || oneShot) return;
201
+ flush();
138
202
  updateWidget();
139
203
  notify(
140
204
  "task-notification",
@@ -146,8 +210,13 @@ export default function backgroundExtension(pi: ExtensionAPI) {
146
210
  let stopRequested = false;
147
211
  let stop: () => void;
148
212
  if (params.command) {
213
+ // Own process group: stop() must end the COMMAND, not just the `$SHELL -c`
214
+ // leader — with zsh a `cmd; echo` or a pipeline otherwise lives on as an
215
+ // orphan holding the stdout pipe, so the task never finishes and, in a
216
+ // one-shot run, the process cannot exit (LIFECYCLE-REVIEW M2, measured).
149
217
  const child = spawn(process.env.SHELL || "/bin/sh", ["-c", params.command], {
150
218
  cwd: ctx.cwd,
219
+ ...detachedSpawnOptions(),
151
220
  stdio: ["ignore", "pipe", "pipe"],
152
221
  });
153
222
  let buffer = "";
@@ -172,7 +241,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
172
241
  );
173
242
  stop = () => {
174
243
  stopRequested = true;
175
- child.kill("SIGTERM");
244
+ stopProcessTree(child, KILL_GRACE_MS);
176
245
  };
177
246
  } else {
178
247
  let ws: WebSocket;
@@ -211,17 +280,50 @@ export default function backgroundExtension(pi: ExtensionAPI) {
211
280
  stop,
212
281
  finished,
213
282
  };
214
- registry.register(task);
215
- updateWidget();
283
+ // A one-shot monitor is awaited below and never addressable afterwards, so
284
+ // it is not registered behind task_output/task_stop (like bash's blocking
285
+ // path); `persistent` cannot be honoured either — the deadline applies.
286
+ if (!oneShot) {
287
+ registry.register(task);
288
+ updateWidget();
289
+ }
216
290
 
217
- if (!params.persistent) {
218
- const timeoutMs = Math.min(params.timeout_ms ?? DEFAULT_MONITOR_TIMEOUT_MS, MAX_MONITOR_TIMEOUT_MS);
291
+ const timeoutMs = Math.min(params.timeout_ms ?? DEFAULT_MONITOR_TIMEOUT_MS, MAX_MONITOR_TIMEOUT_MS);
292
+ if (oneShot || !params.persistent) {
219
293
  const timer = setTimeout(() => {
220
294
  if (task.status === "running") stop();
221
295
  }, timeoutMs);
222
296
  timer.unref?.();
223
297
  }
224
298
 
299
+ if (oneShot) {
300
+ const unhook = whenAborted(signal, stop);
301
+ try {
302
+ await finished;
303
+ } finally {
304
+ unhook();
305
+ }
306
+ const output = persistIfLarge(stored.trim() || "(no events)", { dir: sessionResultsDir(ctx), id: `monitor-${id}` });
307
+ const finalStatus = task.status;
308
+ let how: string = finalStatus;
309
+ if (finalStatus === "stopped") {
310
+ if (signal?.aborted) how = "stopped (tool call aborted)";
311
+ else if (params.persistent) how = `stopped at its default deadline of ${timeoutMs} ms (persistent is not available in a one-shot session)`;
312
+ else how = `stopped at its ${timeoutMs} ms deadline`;
313
+ }
314
+ return {
315
+ content: [
316
+ {
317
+ type: "text",
318
+ text: `Monitor ${id} (${params.description}) ${how} after ${eventCount} event(s). ${oneShotNote("monitor")}\n\n${output}`,
319
+ },
320
+ ],
321
+ // No taskId: nothing is registered behind task_output/task_stop.
322
+ details: { status: finalStatus, events: eventCount },
323
+ isError: finalStatus === "failed",
324
+ };
325
+ }
326
+
225
327
  return {
226
328
  content: [
227
329
  {
@@ -239,7 +341,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
239
341
  label: "Task Output",
240
342
  ...ccToolRenderers("Task Output"),
241
343
  description:
242
- "Retrieve output from a running or finished background task (monitor, background subagent, or background bash) by task id. block=true (default) waits up to `timeout` ms for completion; block=false returns the current status immediately. You never need this just to learn that a task finished — completion always arrives as a system notification carrying the output; call this only when your next step needs the result now, or for a mid-run peek.",
344
+ "Retrieve output from a running or finished background task (monitor, background subagent, or background bash) by task id. block=true (default) waits up to `timeout` ms for completion; block=false returns the current status immediately. You never need this just to learn that a task finished — completion arrives as a system notification with the status and the last 2 KB of output; call this for the rest of the output, when your next step needs the result now, or for a mid-run peek.",
243
345
  parameters: Type.Object({
244
346
  task_id: Type.String({ description: "The task id to get output from" }),
245
347
  block: Type.Optional(Type.Boolean({ description: "Wait for completion (default true)" })),
@@ -328,11 +430,12 @@ export default function backgroundExtension(pi: ExtensionAPI) {
328
430
  title: (a) => (a?.stop ? "stop" : a?.delaySeconds !== undefined ? `${a.delaySeconds}s` : undefined),
329
431
  }),
330
432
  description:
331
- "Schedule when to resume work on a self-paced recurring task: after `delaySeconds` (clamped to [60, 3600]) the given prompt is delivered as a system notification and a new turn starts. One wakeup is pending at a time — scheduling again replaces it; {stop: true} cancels the loop. Do not use this to poll background tasks you started here — their completion already notifies you.",
433
+ "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 given prompt is delivered as a system notification and a new turn starts. 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.",
332
434
  parameters: Type.Object({
333
435
  delaySeconds: Type.Optional(Type.Number({ description: "Seconds from now to wake up, clamped to [60, 3600]. Required unless stop is true" })),
334
436
  prompt: Type.Optional(Type.String({ description: "The task to continue when the wakeup fires. Required unless stop is true" })),
335
437
  reason: Type.Optional(Type.String({ description: "One short sentence explaining the chosen delay; shown to the user. Required unless stop is true" })),
438
+ noop: Type.Optional(Type.Boolean({ description: "true when nothing changed this tick (quiet hold); false when something happened worth keeping" })),
336
439
  stop: Type.Optional(Type.Boolean({ description: "End the loop: cancel any pending wakeup and schedule nothing" })),
337
440
  }),
338
441
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -364,15 +467,15 @@ export default function backgroundExtension(pi: ExtensionAPI) {
364
467
  // Valid reschedule — now it is safe to replace any pending wakeup.
365
468
  clearPending();
366
469
 
367
- const request = { delaySeconds: params.delaySeconds, prompt: params.prompt, reason: params.reason };
470
+ const request = { delaySeconds: params.delaySeconds, prompt: params.prompt, reason: params.reason, noop: params.noop };
368
471
  const delayMs = clampDelaySeconds(params.delaySeconds) * 1000;
369
472
  const timer = setTimeout(() => {
370
473
  wakeup = undefined;
371
- notify("wakeup", buildWakeupMessage(request), { reason: request.reason });
474
+ notify("wakeup", buildWakeupMessage(request), { reason: request.reason, noop: request.noop ?? false });
372
475
  }, delayMs);
373
476
  timer.unref?.();
374
477
  wakeup = { timer, prompt: params.prompt, reason: params.reason };
375
- return { content: [{ type: "text", text: describeSchedule(request) }], details: { delayMs } };
478
+ return { content: [{ type: "text", text: describeSchedule(request) }], details: { delayMs, noop: params.noop ?? false } };
376
479
  },
377
480
  });
378
481
 
@@ -393,13 +496,123 @@ export default function backgroundExtension(pi: ExtensionAPI) {
393
496
  },
394
497
  });
395
498
 
499
+ const clearLoop = () => {
500
+ if (loop) {
501
+ clearInterval(loop.timer);
502
+ loop = undefined;
503
+ }
504
+ };
505
+ const clearWakeup = () => {
506
+ if (wakeup) {
507
+ clearTimeout(wakeup.timer);
508
+ wakeup = undefined;
509
+ }
510
+ };
511
+
512
+ pi.registerCommand("loop", {
513
+ description: "Repeat a task on a loop: `/loop 5m <task>` (fixed interval) or `/loop <task>` (self-paced). `/loop stop` ends it.",
514
+ handler: async (args, ctx) => {
515
+ lastCtx = ctx;
516
+ const raw = (args ?? "").trim();
517
+ const keyword = raw.toLowerCase();
518
+
519
+ if (keyword === "stop") {
520
+ const had = Boolean(loop || wakeup);
521
+ clearLoop();
522
+ clearWakeup();
523
+ if (activatedWakeupTool) {
524
+ const active = pi.getActiveTools();
525
+ if (active.includes("schedule_wakeup")) pi.setActiveTools(active.filter((t) => t !== "schedule_wakeup"));
526
+ activatedWakeupTool = false;
527
+ }
528
+ ctx.ui.notify(had ? "Loop stopped; no further iterations will fire." : "No loop was running.", "info");
529
+ return;
530
+ }
531
+ if (raw === "" || keyword === "status") {
532
+ const parts: string[] = [];
533
+ if (loop) parts.push(`fixed interval every ${loop.intervalSeconds}s — ${loop.task}`);
534
+ if (wakeup) parts.push(`self-paced wakeup pending — ${wakeup.reason}`);
535
+ ctx.ui.notify(
536
+ parts.length
537
+ ? `Running:\n- ${parts.join("\n- ")}\nStop with /loop stop.`
538
+ : "No loop running. Start with `/loop [interval] <task>`, e.g. `/loop 5m check the build`.",
539
+ "info",
540
+ );
541
+ return;
542
+ }
543
+
544
+ const parsed = parseLoopArgs(raw);
545
+ if (!parsed.task) {
546
+ ctx.ui.notify("Give a task to loop, e.g. `/loop 10m check for new PRs` or `/loop watch the deploy`.", "info");
547
+ return;
548
+ }
549
+
550
+ // One loop at a time — replace any existing interval loop or pending wakeup.
551
+ clearLoop();
552
+ clearWakeup();
553
+
554
+ if (parsed.intervalSeconds !== undefined) {
555
+ // Fixed interval, harness-driven: re-arm automatically and fire the first tick now.
556
+ const seconds = clampDelaySeconds(parsed.intervalSeconds);
557
+ const adjusted =
558
+ seconds !== parsed.intervalSeconds
559
+ ? ` (adjusted from ${parsed.intervalSeconds}s; allowed range ${MIN_DELAY_SECONDS}-${MAX_DELAY_SECONDS}s)`
560
+ : "";
561
+ // Skip (don't queue) a tick while the previous tick's turn is still
562
+ // running, so a slow task can't build a backlog of stale iterations.
563
+ const tick = () => {
564
+ if (agentBusy) return;
565
+ notify("loop", buildLoopMessage(parsed.task), { intervalSeconds: seconds });
566
+ };
567
+ const timer = setInterval(tick, seconds * 1000);
568
+ timer.unref?.();
569
+ loop = { timer, intervalSeconds: seconds, task: parsed.task };
570
+ ctx.ui.notify(`Looping every ${seconds}s${adjusted}: ${parsed.task}. Stop with /loop stop.`, "info");
571
+ tick(); // fire the first iteration now
572
+ return;
573
+ }
574
+
575
+ // Dynamic (self-paced): the model drives schedule_wakeup, so make sure it is
576
+ // active — remembering we did, so /loop stop can restore the lean surface.
577
+ const active = pi.getActiveTools();
578
+ if (!active.includes("schedule_wakeup")) {
579
+ pi.setActiveTools([...active, "schedule_wakeup"]);
580
+ activatedWakeupTool = true;
581
+ }
582
+ ctx.ui.notify(`Self-paced loop started: ${parsed.task}. Stop with /loop stop.`, "info");
583
+ notify("loop", buildDynamicLoopPrompt(parsed.task), {});
584
+ },
585
+ });
586
+
396
587
  pi.on("session_start", (_event, ctx) => {
397
588
  lastCtx = ctx;
589
+ if (pendingShutdownNotice) {
590
+ const notice = pendingShutdownNotice;
591
+ pendingShutdownNotice = undefined;
592
+ if (ctx.hasUI) ctx.ui.notify(notice, "info");
593
+ }
594
+ });
595
+ pi.on("agent_start", () => {
596
+ agentBusy = true;
597
+ });
598
+ pi.on("agent_settled", () => {
599
+ agentBusy = false;
398
600
  });
399
601
 
400
- pi.on("session_shutdown", () => {
602
+ pi.on("session_shutdown", (event) => {
603
+ // Fires on /clear, /new, /resume and /reload too (findings §8). Everything
604
+ // running dies with this instance — the replacement instance has no handle
605
+ // on it — and the replacement is told (see pendingShutdownNotice); a quit
606
+ // needs no note. A reload keeps the conversation, so its note says so.
607
+ const running = registry.running();
608
+ if (running.length > 0 && event.reason !== "quit") {
609
+ const names = running.map((t) => `${t.id} (${t.description})`).join(", ");
610
+ const when = event.reason === "reload" ? "on reload" : "with the previous session";
611
+ pendingShutdownNotice = `Stopped ${running.length} background task${running.length === 1 ? "" : "s"} ${when}: ${names}.`;
612
+ }
401
613
  registry.stopAll();
402
- if (wakeup) clearTimeout(wakeup.timer);
403
- wakeup = undefined;
614
+ clearWakeup();
615
+ clearLoop();
616
+ lastCtx = undefined;
404
617
  });
405
618
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Monitor event batching (pure). A monitor turns every stdout line into a
3
+ * steered notification; `tail -f` on a busy log used to mean an unbounded
4
+ * batch every second, each one a turn when the session was idle. Two limits:
5
+ *
6
+ * - a batch shows at most MAX_LINES lines / MAX_CHARS characters and says how
7
+ * many more arrived (the full stream stays behind `task_output`);
8
+ * - while the agent is mid-turn the flush window widens (BUSY_MS), so events
9
+ * coalesce into one notification at the next tool boundary instead of one
10
+ * steer per second. Idle, the short window keeps latency low; the turn it
11
+ * starts then widens the window for what follows.
12
+ */
13
+
14
+ export const MONITOR_BATCH_MAX_LINES = 50;
15
+ export const MONITOR_BATCH_MAX_CHARS = 4_000;
16
+ export const MONITOR_BATCH_IDLE_MS = 1_000;
17
+ export const MONITOR_BATCH_BUSY_MS = 10_000;
18
+
19
+ export interface MonitorBatch {
20
+ /** Lines kept for display, in order (at most MAX_LINES). */
21
+ shown: string[];
22
+ /** Lines that arrived beyond the cap; counted, not kept. */
23
+ overflow: number;
24
+ }
25
+
26
+ export function emptyBatch(): MonitorBatch {
27
+ return { shown: [], overflow: 0 };
28
+ }
29
+
30
+ /** Add one event line, keeping the batch bounded. */
31
+ export function pushEvent(batch: MonitorBatch, line: string): void {
32
+ if (batch.shown.length < MONITOR_BATCH_MAX_LINES) batch.shown.push(line);
33
+ else batch.overflow++;
34
+ }
35
+
36
+ export function batchSize(batch: MonitorBatch): number {
37
+ return batch.shown.length + batch.overflow;
38
+ }
39
+
40
+ /** The notification body for one flushed batch. */
41
+ export function formatMonitorBatch(id: string, description: string, batch: MonitorBatch): string {
42
+ const total = batchSize(batch);
43
+ const lines: string[] = [];
44
+ let chars = 0;
45
+ let hidden = batch.overflow;
46
+ for (const line of batch.shown) {
47
+ if (chars + line.length + 1 > MONITOR_BATCH_MAX_CHARS && lines.length > 0) {
48
+ hidden += batch.shown.length - lines.length;
49
+ break;
50
+ }
51
+ const shown = line.length > MONITOR_BATCH_MAX_CHARS ? `${line.slice(0, MONITOR_BATCH_MAX_CHARS)}…` : line;
52
+ lines.push(shown);
53
+ chars += shown.length + 1;
54
+ }
55
+ const head = `Monitor ${id} (${description}) emitted ${total} event(s):`;
56
+ const more = hidden > 0 ? `\n… +${hidden} more line(s) not shown — task_output ${id} has the full stream` : "";
57
+ return `${head}\n${lines.join("\n")}${more}`;
58
+ }
@@ -20,6 +20,8 @@ export interface BackgroundTask {
20
20
  status: BackgroundStatus;
21
21
  startedAt: number;
22
22
  finishedAt?: number;
23
+ /** The raw shell command, when the task wraps one (shown by the shell panel). */
24
+ command?: string;
23
25
  /** File the task spools its output to, when it has one. */
24
26
  logPath?: string;
25
27
  /** Output accumulated so far (or final output once finished). */
@@ -33,6 +35,13 @@ export interface BackgroundTask {
33
35
  * terminate it even though the task itself already completed.
34
36
  */
35
37
  resident?: () => boolean;
38
+ /**
39
+ * The task already has dedicated panel UI showing it live (bash shells and
40
+ * subagent runs render in the subagents panel), so ambient counters like
41
+ * the " background tasks: N" widget must not double-count it. Set by the
42
+ * producer — never re-derived from `kind` by consumers.
43
+ */
44
+ ownUI?: boolean;
36
45
  }
37
46
 
38
47
  export function generateTaskId(): string {
@@ -16,6 +16,51 @@ export interface WakeupRequest {
16
16
  delaySeconds: number;
17
17
  prompt: string;
18
18
  reason: string;
19
+ /** Nothing changed this tick (matches CC's /loop noop flag). Recorded for callers; no effect on scheduling. */
20
+ noop?: boolean;
21
+ }
22
+
23
+ export interface ParsedLoop {
24
+ /** undefined = dynamic (model self-paces via schedule_wakeup); set = fixed interval. */
25
+ intervalSeconds?: number;
26
+ task: string;
27
+ }
28
+
29
+ /**
30
+ * Parse `/loop` arguments: a leading duration token (`5m`, `30s`, `2h`) selects
31
+ * fixed-interval mode and the rest is the task; no duration selects dynamic
32
+ * (self-paced) mode. `stop` / `status` are handled by the caller, not here.
33
+ */
34
+ export function parseLoopArgs(raw: string): ParsedLoop {
35
+ const m = raw.trim().match(/^(\d+)\s*(s|m|h)\b\s*([\s\S]*)$/i);
36
+ if (m) {
37
+ const n = Number(m[1]);
38
+ const unit = m[2].toLowerCase();
39
+ const seconds = unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
40
+ return { intervalSeconds: seconds, task: m[3].trim() };
41
+ }
42
+ return { task: raw.trim() };
43
+ }
44
+
45
+ /** A loop follow-up: an instruction line, a blank, then the task — system-framed. */
46
+ function framedLoop(lead: string, task: string): string {
47
+ return systemNotification([lead, "", task].join("\n"));
48
+ }
49
+
50
+ /** The follow-up a fixed-interval `/loop` tick delivers. */
51
+ export function buildLoopMessage(task: string): string {
52
+ return framedLoop(
53
+ "Loop tick — run the task below now. This repeats automatically on the interval and keeps firing until the user runs `/loop stop`.",
54
+ task,
55
+ );
56
+ }
57
+
58
+ /** The opening message for a dynamic (self-paced) `/loop` — instructs the model to drive schedule_wakeup. */
59
+ export function buildDynamicLoopPrompt(task: string): string {
60
+ return framedLoop(
61
+ "Self-paced loop started. Work the task below now. When this iteration is done, call schedule_wakeup to schedule the next one — choose delaySeconds by what you're waiting for, and pass the same task back as `prompt`. End the loop with schedule_wakeup {stop: true} when the task is complete or the user says to stop.",
62
+ task,
63
+ );
19
64
  }
20
65
 
21
66
  /** The follow-up message a fired wakeup delivers. Framed so it never reads as user input. */