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
@@ -4,17 +4,57 @@
4
4
  * pi's `context` event; they are never written to the session file.
5
5
  *
6
6
  * Cross-extension contract: emit on the event bus channel
7
- * `one-code:system-reminder` with `{ text, scope? }` to enqueue from any
8
- * extension (including third-party ones). Extensions in this package can also
9
- * import `reminderQueue` directly.
7
+ * `one-code:system-reminder` with `{ text, scope?, placement? }` to enqueue from
8
+ * any extension (including third-party ones). The queue instance itself lives
9
+ * in the system-reminder extension; do not import one from here (jiti gives
10
+ * every extension its own module instance, so a shared export steers nothing).
10
11
  *
11
- * Placement mirrors Claude Code's first-user-message reminder stack. Session
12
- * context reminders (deferred tools, agent catalog, MCP instructions, skills,
13
- * the `# claudeMd` block) are `first-prepend`: they ride at the front of the
14
- * FIRST user message, before the user's text, ordered by `order`. Turn-steering
15
- * reminders (file changed, mode cycled, memory near-limit) are `last-append`:
16
- * appended to the LAST user message, the Claude Code default. See
17
- * docs/decisions and the captured payloads (opus-4-8.json / latest-haiku.json).
12
+ * Three placements, chosen by what kind of fact the reminder is:
13
+ *
14
+ * - `first-prepend` — SESSION CONTEXT that never changes mid-session (deferred
15
+ * tools, agent catalog, MCP instructions, skills, `# claudeMd`). Front of the
16
+ * FIRST user message, before the user's text, ordered by `order`, exactly
17
+ * Claude Code's first-message stack. Byte-stable, so the cached prefix holds.
18
+ * - `sticky-append` — SESSION STATE that switches on and off (auto mode, plan
19
+ * mode, a worktree session, ultracode). Appended to the user message that
20
+ * opened the turn during which the state switched on (`since`) and to EVERY
21
+ * user message after it, so message N carries the block on every later
22
+ * request too: the prefix stays byte-stable while the state holds, and the
23
+ * model still sees the reminder on its latest turn. Switching off drops the
24
+ * blocks (one cache miss, once). The old `last-append` + every-turn
25
+ * combination re-cached the previous turn on every turn — permanently, in
26
+ * auto mode.
27
+ * - `last-append` — ONE-SHOT STEERING about what just happened (a deferred tool
28
+ * miss, a file changed under the model, a mode change). Delivered where the
29
+ * model reads next, and then KEPT there so the message never changes again:
30
+ * a one-shot pending when a tool result is stored is written INTO that result
31
+ * (`takeOneShots` from the `tool_result` hook — Claude Code persists its
32
+ * mid-turn reminders the same way, so the transcript carries them and no
33
+ * request ever re-caches the result); a one-shot still pending when a request
34
+ * goes out is `pin`ned to the request's tail (the trailing tool result by call
35
+ * id, else the last user message by timestamp) and re-attached to that same
36
+ * message on every later request, byte-identical. Pins are process memory
37
+ * (a `--resume` drops them: one miss, once).
38
+ *
39
+ * Persisted or pinned is decided by WHEN the one-shot is emitted, which is a
40
+ * load-order rule: system-reminder's `tool_result` hook takes the one-shots
41
+ * pending at that moment, so only an extension whose own `tool_result` handler
42
+ * runs BEFORE it (listed before `system-reminder` in `package.json`
43
+ * `pi.extensions`, like `context-budget`) or that emits from `tool_call` gets
44
+ * its block written into the result. Emitting from a later `tool_result`
45
+ * handler, or from `tool_execution_end` (pi runs it AFTER the `tool_result`
46
+ * hooks, findings §3), lands the block on the same result but as a pin. Same
47
+ * wording on the wire, so this only matters for `--resume` and for ordering
48
+ * after the `<total_tokens>` line.
49
+ *
50
+ * A compaction summary counts as a user message for anchoring (pi renders it as
51
+ * one), so the context stack survives a compaction that left no user turn. So
52
+ * does a `custom` harness message (task notification, wakeup, hook context):
53
+ * pi sends it as a user message, and a turn it opens has no other user turn.
54
+ *
55
+ * Retry safety falls out of this: pi re-runs the `context` transform per LLM
56
+ * attempt, and both a persisted and a pinned one-shot are present on every
57
+ * attempt by construction.
18
58
  */
19
59
 
20
60
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
@@ -25,12 +65,11 @@ export const REMINDER_CHANNEL = "one-code:system-reminder";
25
65
  /** "next-turn" fires once on the next LLM call; "every-turn" fires on every call until removed. */
26
66
  export type ReminderScope = "next-turn" | "every-turn";
27
67
 
28
- /**
29
- * `first-prepend` → front of the first user message, before the user text,
30
- * sorted by `order` (Claude Code's context stack). `last-append` → end of the
31
- * last user message (Claude Code's steering default).
32
- */
33
- export type ReminderPlacement = "first-prepend" | "last-append";
68
+ /** See the module header for which placement fits which kind of reminder. */
69
+ export type ReminderPlacement = "first-prepend" | "sticky-append" | "last-append";
70
+
71
+ /** Where a delivered one-shot stays: a tool result (by call id) or a user turn (by timestamp). */
72
+ export type PinAnchor = { kind: "toolResult"; toolCallId: string } | { kind: "user"; timestamp: number };
34
73
 
35
74
  /**
36
75
  * `order` values for the `first-prepend` context stack, matching Claude Code's
@@ -42,9 +81,17 @@ export const CONTEXT_ORDER = {
42
81
  deferredTools: 10,
43
82
  subagentModels: 20,
44
83
  agents: 21,
84
+ /** Tiny-tier-only strict delegation directive, right after the catalog. */
85
+ delegation: 22,
45
86
  mcp: 30,
46
87
  skills: 40,
88
+ // CLAUDE.md-family (with AGENTS.md as a per-directory fallback when a directory
89
+ // has no CLAUDE.md) — CLAUDE.md > AGENTS.md.
47
90
  claudeMd: 50,
91
+ // One Code's own instructions ride in their own block AFTER the # claudeMd
92
+ // block (higher `order` = closer to the user text = higher precedence), so
93
+ // ONECODE.md takes precedence over CLAUDE.md/AGENTS.md. Not part of CC.
94
+ oneCodeMd: 60,
48
95
  } as const;
49
96
 
50
97
  /** A drained reminder with everything the injector needs to place it. */
@@ -58,6 +105,20 @@ export interface ReminderEntry {
58
105
  * reproduces that byte-for-byte. Defaults to "".
59
106
  */
60
107
  suffix?: string;
108
+ /**
109
+ * `sticky-append` only: when the state switched on. The user message that
110
+ * opened the turn in progress at that moment, and every user message after
111
+ * it, carry the block.
112
+ */
113
+ since?: number;
114
+ /** Set on a pinned one-shot: the exact message it rides on every request. */
115
+ pin?: PinAnchor;
116
+ /**
117
+ * Emit `text` as-is instead of inside a `<system-reminder>` frame — for the
118
+ * few Claude Code blocks that ride bare, like `<total_tokens>` after a tool
119
+ * result. Defaults to false.
120
+ */
121
+ raw?: boolean;
61
122
  }
62
123
 
63
124
  export interface ReminderPayload {
@@ -73,6 +134,14 @@ export interface ReminderPayload {
73
134
  order?: number;
74
135
  /** Literal text appended after the closing `</system-reminder>` tag. Defaults to "". */
75
136
  suffix?: string;
137
+ /** Emit the text bare, with no `<system-reminder>` frame. */
138
+ raw?: boolean;
139
+ /**
140
+ * `sticky-append` only: anchor the block from this timestamp instead of now —
141
+ * `0` puts it on every user message in the session, including ones a resume
142
+ * brought back (the `<total_tokens>` line rides every user message in CC).
143
+ */
144
+ since?: number;
76
145
  }
77
146
 
78
147
  interface StoredReminder extends ReminderEntry {
@@ -85,21 +154,44 @@ type EnqueueOptions = {
85
154
  placement?: ReminderPlacement;
86
155
  order?: number;
87
156
  suffix?: string;
157
+ raw?: boolean;
158
+ /** Test seam / explicit anchor for `sticky-append`; defaults to now. */
159
+ since?: number;
88
160
  };
89
161
 
90
162
  export class ReminderQueue {
91
163
  private nextTurn: StoredReminder[] = [];
164
+ /** Delivered one-shots, each fixed to the message it first rode (see `pin`). */
165
+ private pinned: StoredReminder[] = [];
92
166
  private everyTurn = new Map<string, StoredReminder>();
167
+ private readonly now: () => number;
168
+
169
+ constructor(now: () => number = Date.now) {
170
+ this.now = now;
171
+ }
93
172
 
94
173
  enqueue(text: string, opts?: EnqueueOptions): void {
95
174
  if (!text.trim()) return;
175
+ const placement = opts?.placement ?? "last-append";
96
176
  const entry: StoredReminder = {
97
177
  text,
98
- placement: opts?.placement ?? "last-append",
178
+ placement,
99
179
  order: opts?.order ?? 0,
100
180
  suffix: opts?.suffix,
101
181
  key: opts?.key,
182
+ raw: opts?.raw,
102
183
  };
184
+ if (placement === "sticky-append") {
185
+ // A standing reminder re-emitted with the SAME text (plan mode re-emits
186
+ // every turn) keeps its anchor, so the blocks on earlier messages do not
187
+ // move. Different text under the same key is a new fact (plan → auto
188
+ // under the shared "permission-mode" key) and anchors from now.
189
+ const previous = opts?.key !== undefined ? this.everyTurn.get(opts.key) : undefined;
190
+ entry.since =
191
+ opts?.since ??
192
+ (previous?.placement === "sticky-append" && previous.text === text ? previous.since : undefined) ??
193
+ this.now();
194
+ }
103
195
  if (opts?.scope === "every-turn") {
104
196
  this.everyTurn.set(opts.key ?? text, entry);
105
197
  } else {
@@ -115,98 +207,259 @@ export class ReminderQueue {
115
207
  this.everyTurn.delete(key);
116
208
  }
117
209
 
118
- /** Returns pending reminders. Clears next-turn reminders; every-turn ones persist. */
119
- drain(): ReminderEntry[] {
120
- const drained: ReminderEntry[] = [
121
- ...[...this.everyTurn.values()].map(strip),
122
- ...this.nextTurn.map(strip),
123
- ];
210
+ /**
211
+ * Take the pending `last-append` one-shots out of the queue — for the
212
+ * `tool_result` hook, which writes them into the stored result. Other
213
+ * placements stay queued for the next request.
214
+ */
215
+ takeOneShots(): ReminderEntry[] {
216
+ const taken = this.nextTurn.filter((r) => r.placement === "last-append");
217
+ this.nextTurn = this.nextTurn.filter((r) => r.placement !== "last-append");
218
+ return taken.map(strip);
219
+ }
220
+
221
+ /**
222
+ * Fix the pending `last-append` one-shots to `anchor` — the message they ride
223
+ * on this request — so every later request re-attaches them there unchanged.
224
+ * Called by the owner at `context` time, after it has decided the tail.
225
+ */
226
+ pin(anchor: PinAnchor): void {
227
+ for (const entry of this.nextTurn) {
228
+ if (entry.placement !== "last-append") continue;
229
+ this.pinned.push({ ...entry, pin: anchor });
230
+ }
231
+ this.nextTurn = this.nextTurn.filter((r) => r.placement !== "last-append");
232
+ }
233
+
234
+ /**
235
+ * Everything to inject on this request: every-turn state, pinned one-shots,
236
+ * then whatever is still pending. `messages` is the request being built:
237
+ * pins whose anchor is no longer in it (compacted away, or left behind on
238
+ * another branch) are dropped here, as part of draining, so no caller can
239
+ * forget the step. Nothing else is ever evicted: a pin is one small object,
240
+ * and removing a pin whose message is still in context would change that
241
+ * message and re-cache everything after it — the old "oldest past 400" cap
242
+ * did exactly that (CACHE-REVIEW-2026-09-04 M3).
243
+ */
244
+ drain(messages: AgentMessage[]): ReminderEntry[] {
245
+ if (this.pinned.length > 0) {
246
+ const locate = pinLocator(messages);
247
+ this.pinned = this.pinned.filter((entry) => locate(entry.pin as PinAnchor) !== -1);
248
+ }
249
+ const pending = this.nextTurn.map(strip);
124
250
  this.nextTurn = [];
125
- return drained;
251
+ return [...[...this.everyTurn.values()].map(strip), ...this.pinned.map(strip), ...pending];
126
252
  }
127
253
 
128
254
  get size(): number {
129
- return this.everyTurn.size + this.nextTurn.length;
255
+ return this.everyTurn.size + this.nextTurn.length + this.pinned.length;
256
+ }
257
+
258
+ /** True when a one-shot is waiting to be delivered (the owner decides where). */
259
+ get hasPendingOneShots(): boolean {
260
+ return this.nextTurn.some((r) => r.placement === "last-append");
130
261
  }
131
262
  }
132
263
 
133
264
  function strip(r: StoredReminder): ReminderEntry {
134
- return { text: r.text, placement: r.placement, order: r.order, suffix: r.suffix };
265
+ const entry: ReminderEntry = { text: r.text, placement: r.placement, order: r.order, suffix: r.suffix };
266
+ if (r.raw) entry.raw = true;
267
+ if (r.since !== undefined) entry.since = r.since;
268
+ if (r.pin !== undefined) entry.pin = r.pin;
269
+ return entry;
270
+ }
271
+
272
+ /**
273
+ * One pass over `messages` → a lookup from a pin to the index of the message it
274
+ * rides (-1 when that message left the context). Built once per request by the
275
+ * queue's drain and once by injectReminders, so pin count never multiplies the
276
+ * message count on the per-request path.
277
+ */
278
+ function pinLocator(messages: AgentMessage[]): (pin: PinAnchor) => number {
279
+ const byToolCall = new Map<string, number>();
280
+ const byTimestamp = new Map<number, number>();
281
+ messages.forEach((m, index) => {
282
+ if (m.role === "toolResult") {
283
+ const id = (m as { toolCallId?: string }).toolCallId;
284
+ if (typeof id === "string" && !byToolCall.has(id)) byToolCall.set(id, index);
285
+ } else if (isUserLike(m)) {
286
+ const stamp = (m as { timestamp?: number }).timestamp;
287
+ if (typeof stamp === "number" && !byTimestamp.has(stamp)) byTimestamp.set(stamp, index);
288
+ }
289
+ });
290
+ return (pin) => (pin.kind === "toolResult" ? byToolCall.get(pin.toolCallId) : byTimestamp.get(pin.timestamp)) ?? -1;
291
+ }
292
+
293
+ /** The anchor a one-shot lands on for this request: the trailing tool result, else the last user-like message. */
294
+ export function tailAnchor(messages: AgentMessage[]): PinAnchor | undefined {
295
+ const last = messages[messages.length - 1] as (AgentMessage & { toolCallId?: string }) | undefined;
296
+ if (last?.role === "toolResult" && typeof last.toolCallId === "string") return { kind: "toolResult", toolCallId: last.toolCallId };
297
+ for (let i = messages.length - 1; i >= 0; i--) {
298
+ const m = messages[i] as AgentMessage & { timestamp?: number };
299
+ if (isUserLike(m) && typeof m.timestamp === "number") {
300
+ return { kind: "user", timestamp: m.timestamp };
301
+ }
302
+ }
303
+ return undefined;
135
304
  }
136
305
 
137
- /** Shared queue instance for extensions in this package. */
138
- export const reminderQueue = new ReminderQueue();
306
+ /** A tool result's content with reminder blocks appended (for the `tool_result` hook). */
307
+ export function appendReminderBlocks(content: ContentBlock[], entries: ReminderEntry[]): ContentBlock[] {
308
+ return [...content, ...entries.map(reminderBlock)];
309
+ }
310
+
311
+ /**
312
+ * The framings a reminder/countdown block appended onto a tool result begins
313
+ * with: a `<system-reminder>`-wrapped one-shot, or a raw one-shot (the
314
+ * context-budget `<total_tokens>` countdown, an LSP `<new-diagnostics>` block).
315
+ * A PostToolUse hook that replaces the whole result must re-attach the trailing
316
+ * run of these, or it silently drops the harness's own context (review M3).
317
+ */
318
+ const APPENDED_REMINDER_PREFIXES = ["<system-reminder>", "<total_tokens>", "<new-diagnostics>"];
319
+
320
+ /** True when a text block is one the reminder queue appended onto a tool result. */
321
+ export function isAppendedReminderText(text: string): boolean {
322
+ return APPENDED_REMINDER_PREFIXES.some((prefix) => text.startsWith(prefix));
323
+ }
139
324
 
140
325
  export function wrapReminder(text: string): string {
141
326
  return `<system-reminder>\n${text}\n</system-reminder>`;
142
327
  }
143
328
 
329
+ /**
330
+ * pi's `convertToLlm` renders a `compactionSummary` message as a user message
331
+ * with exactly this frame (`core/messages.js`, not exported through the
332
+ * package's exports map — copied here and locked by a unit test). Used when a
333
+ * reminder must anchor to the summary because no user turn survived compaction.
334
+ */
335
+ export const COMPACTION_SUMMARY_PREFIX =
336
+ "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
337
+ export const COMPACTION_SUMMARY_SUFFIX = "\n</summary>";
338
+
144
339
  type ContentBlock = TextContent | ImageContent;
145
- type UserMessage = AgentMessage & { content: string | ContentBlock[] };
146
340
 
147
341
  function toBlocks(content: string | ContentBlock[]): ContentBlock[] {
148
342
  return typeof content === "string" ? [{ type: "text", text: content }] : [...content];
149
343
  }
150
344
 
151
345
  function reminderBlock(entry: ReminderEntry): TextContent {
152
- return { type: "text", text: wrapReminder(entry.text) + (entry.suffix ?? "") };
346
+ return { type: "text", text: (entry.raw ? entry.text : wrapReminder(entry.text)) + (entry.suffix ?? "") };
347
+ }
348
+
349
+ /**
350
+ * Roles a reminder block can be attached to. `custom` is a harness message
351
+ * (task notification, wakeup, hook context, `<new-diagnostics>`); pi's
352
+ * `convertToLlm` sends it to the model as `role: "user"`, so on the wire it IS
353
+ * a user turn — and a turn opened by one (a `/loop` tick, an agent report
354
+ * arriving while idle) has no `user` message of its own. Before 2026-09-05 such
355
+ * a request got no context stack at all, and its pending one-shots pinned to
356
+ * the previous user message (STEERING-REVIEW-2026-09-05 H1).
357
+ */
358
+ function isUserLike(m: AgentMessage): boolean {
359
+ return isStickyCarrier(m) || m.role === "compactionSummary";
360
+ }
361
+
362
+ /** User-role messages that carry sticky blocks: real turns and harness messages, not a compaction summary. */
363
+ function isStickyCarrier(m: AgentMessage): boolean {
364
+ return m.role === "user" || m.role === "custom";
365
+ }
366
+
367
+ /** A copy of `message` with `before` blocks in front of its content and `after` blocks behind. */
368
+ function withBlocks(message: AgentMessage, before: TextContent[], after: TextContent[]): AgentMessage {
369
+ if (before.length === 0 && after.length === 0) return message;
370
+ if (message.role === "compactionSummary") {
371
+ const summary = message as unknown as { summary: string; timestamp: number };
372
+ return {
373
+ role: "user",
374
+ content: [
375
+ ...before,
376
+ { type: "text", text: COMPACTION_SUMMARY_PREFIX + summary.summary + COMPACTION_SUMMARY_SUFFIX },
377
+ ...after,
378
+ ],
379
+ timestamp: summary.timestamp,
380
+ } as AgentMessage;
381
+ }
382
+ const target = message as AgentMessage & { content: string | ContentBlock[] };
383
+ return { ...target, content: [...before, ...toBlocks(target.content), ...after] } as AgentMessage;
153
384
  }
154
385
 
155
386
  /**
156
387
  * Returns a copy of `messages` with reminder blocks injected (Claude Code
157
- * convention: reminders ride inside user-message content blocks). `first-prepend`
158
- * entries go to the front of the first user message, sorted by `order`, before
159
- * the user's text; `last-append` entries go to the end of the last user message.
160
- * A bare string is treated as `last-append` (back-compat). The input array and
161
- * its messages are not mutated. With no user message, messages are returned
162
- * unchanged and the caller re-enqueues as needed.
388
+ * convention: reminders ride inside message content blocks, never as messages
389
+ * of their own). See the module header for the three placements. A bare string
390
+ * is `last-append` (back-compat). The input array and its messages are not
391
+ * mutated. With nothing to anchor to (no user, tool-result, or compaction
392
+ * summary message), messages are returned unchanged and the caller keeps the
393
+ * reminders queued.
163
394
  */
164
- export function injectReminders(
165
- messages: AgentMessage[],
166
- reminders: Array<string | ReminderEntry>,
167
- ): AgentMessage[] {
395
+ export function injectReminders(messages: AgentMessage[], reminders: Array<string | ReminderEntry>): AgentMessage[] {
168
396
  if (reminders.length === 0) return messages;
169
397
 
170
398
  const entries: ReminderEntry[] = reminders.map((r) =>
171
399
  typeof r === "string" ? { text: r, placement: "last-append", order: 0 } : r,
172
400
  );
173
-
174
401
  const firstPrepend = entries
175
402
  .filter((e) => e.placement === "first-prepend")
176
403
  .map((e, i) => ({ e, i }))
177
404
  .sort((a, b) => a.e.order - b.e.order || a.i - b.i)
178
405
  .map((x) => x.e);
179
- const lastAppend = entries.filter((e) => e.placement === "last-append");
406
+ const sticky = entries.filter((e) => e.placement === "sticky-append");
407
+ const pinnedEntries = entries.filter((e) => e.pin !== undefined);
408
+ const lastAppend = entries.filter((e) => e.placement === "last-append" && e.pin === undefined);
180
409
 
181
- const firstUserIndex = messages.findIndex((m) => m.role === "user");
182
- if (firstUserIndex === -1) return messages;
183
- const lastUserIndex = messages.findLastIndex((m) => m.role === "user");
410
+ const firstUserIndex = messages.findIndex(isUserLike);
411
+ const lastUserIndex = messages.findLastIndex(isUserLike);
412
+ const lastIndex = messages.length - 1;
413
+ // Mid-turn, the request ends in the tool result(s) the model is about to
414
+ // read — that is where a one-shot reminder belongs. Otherwise the last user turn.
415
+ const tailIndex = lastIndex >= 0 && messages[lastIndex].role === "toolResult" ? lastIndex : lastUserIndex;
416
+ if (firstUserIndex === -1 && tailIndex === -1) return messages;
184
417
 
185
- const result = [...messages];
418
+ const before = new Map<number, TextContent[]>();
419
+ const after = new Map<number, TextContent[]>();
420
+ const push = (map: Map<number, TextContent[]>, index: number, blocks: TextContent[]) => {
421
+ if (index < 0 || blocks.length === 0) return;
422
+ map.set(index, [...(map.get(index) ?? []), ...blocks]);
423
+ };
186
424
 
187
- if (firstUserIndex === lastUserIndex) {
188
- const target = result[firstUserIndex] as UserMessage;
189
- const blocks = toBlocks(target.content);
190
- result[firstUserIndex] = {
191
- ...target,
192
- content: [...firstPrepend.map(reminderBlock), ...blocks, ...lastAppend.map(reminderBlock)],
193
- } as AgentMessage;
194
- return result;
195
- }
425
+ push(before, firstUserIndex === -1 ? tailIndex : firstUserIndex, firstPrepend.map(reminderBlock));
196
426
 
197
- if (firstPrepend.length > 0) {
198
- const target = result[firstUserIndex] as UserMessage;
199
- result[firstUserIndex] = {
200
- ...target,
201
- content: [...firstPrepend.map(reminderBlock), ...toBlocks(target.content)],
202
- } as AgentMessage;
427
+ for (const entry of sticky) {
428
+ const since = entry.since ?? 0;
429
+ // The block rides the user message that opened the turn during which the
430
+ // state switched on (the latest user message stamped before `since` — a
431
+ // standing reminder emitted on before_agent_start is always stamped after
432
+ // the turn's user message) and every user message after it. The set only
433
+ // grows while the state holds, so earlier messages never change.
434
+ let opener = -1;
435
+ const carriers: number[] = [];
436
+ messages.forEach((m, index) => {
437
+ if (!isStickyCarrier(m)) return;
438
+ const stamp = (m as { timestamp?: number }).timestamp ?? 0;
439
+ if (stamp >= since) carriers.push(index);
440
+ else opener = index;
441
+ });
442
+ if (opener !== -1) carriers.unshift(opener);
443
+ if (carriers.length === 0) {
444
+ // No user turn at all (overflow compaction mid-turn): ride the tail.
445
+ push(after, tailIndex === -1 ? firstUserIndex : tailIndex, [reminderBlock(entry)]);
446
+ continue;
447
+ }
448
+ for (const index of carriers) push(after, index, [reminderBlock(entry)]);
203
449
  }
204
- if (lastAppend.length > 0) {
205
- const target = result[lastUserIndex] as UserMessage;
206
- result[lastUserIndex] = {
207
- ...target,
208
- content: [...toBlocks(target.content), ...lastAppend.map(reminderBlock)],
209
- } as AgentMessage;
450
+
451
+ push(after, tailIndex === -1 ? firstUserIndex : tailIndex, lastAppend.map(reminderBlock));
452
+
453
+ // Pinned one-shots ride the exact message they first landed on; a message
454
+ // compacted away simply no longer carries its pin.
455
+ if (pinnedEntries.length > 0) {
456
+ const locate = pinLocator(messages);
457
+ for (const entry of pinnedEntries) {
458
+ const index = locate(entry.pin as PinAnchor);
459
+ if (index !== -1) push(after, index, [reminderBlock(entry)]);
460
+ }
210
461
  }
211
- return result;
462
+
463
+ if (before.size === 0 && after.size === 0) return messages;
464
+ return messages.map((m, index) => withBlocks(m, before.get(index) ?? [], after.get(index) ?? []));
212
465
  }
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Session scratchpad — Claude Code's designated temp-files directory.
2
+ * Session scratchpad — a designated temp-files directory.
3
3
  *
4
- * Claude Code puts one under `<tmp>/claude-<uid>/<project-slug>/<session-id>/scratchpad`
5
- * and tells the model (via a system-prompt section) to use it for everything
6
- * that would otherwise land in `/tmp` or leak into the project. The path is
7
- * per-session, so parallel sessions on one project never collide.
4
+ * The shape follows Claude Code's (`<tmp>/<owner>/<project-slug>/<session-id>/scratchpad`),
5
+ * but the owner segment carries One Code's name, not Claude Code's, so a machine
6
+ * running both products never interleaves their scratchpads under one owner dir
7
+ * (distribution review 2026-09-09, H2). We tell the model (via a system-prompt
8
+ * section) to use it for everything that would otherwise land in `/tmp` or leak
9
+ * into the project. The path is per-session, so parallel sessions on one project
10
+ * never collide.
8
11
  *
9
12
  * Three extensions need the same path (system-prompt renders the section,
10
13
  * permissions and the workflow gate allow writes into it), and jiti isolates
@@ -17,14 +20,14 @@ import { join } from "node:path";
17
20
  import { findGitRoot } from "./git.ts";
18
21
  import { projectSlug } from "./memory.ts";
19
22
 
20
- /** Pure core, testable: the Claude Code path shape. */
23
+ /** Pure core, testable: Claude Code's path shape under a One Code-named owner dir. */
21
24
  export function scratchpadDir(
22
25
  tmpRoot: string,
23
26
  uid: number | undefined,
24
27
  projectRoot: string,
25
28
  sessionId: string,
26
29
  ): string {
27
- const owner = uid === undefined ? "claude" : `claude-${uid}`;
30
+ const owner = uid === undefined ? "onecode" : `onecode-${uid}`;
28
31
  return join(tmpRoot, owner, projectSlug(projectRoot), sessionId, "scratchpad");
29
32
  }
30
33
 
@@ -34,7 +37,7 @@ export function scratchpadDir(
34
37
  * resolved subject all name the same real location. Falls back to os.tmpdir()
35
38
  * where /tmp does not exist.
36
39
  */
37
- export function resolveTmpRoot(): string {
40
+ function resolveTmpRoot(): string {
38
41
  try {
39
42
  return realpathSync("/tmp");
40
43
  } catch {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * "Is this extension instance's session still alive?" — the one flag every
3
+ * extension with late work (a child's `close`, a debounce, a panel action's
4
+ * continuation) needs. After `session_shutdown` (which `/clear`, `/new`,
5
+ * `/resume` and process exit all emit — findings §8) every `pi.*` call and
6
+ * every getter on a captured ctx throws; a callback that fires afterwards must
7
+ * do nothing. Before this module each extension kept its own `shuttingDown`
8
+ * boolean wired to the same two events (LIFECYCLE-REVIEW-2026-09-06 fix pass).
9
+ *
10
+ * Call it FIRST in the factory: its handlers register before the extension's
11
+ * own, so `alive()` already reads false inside the extension's
12
+ * `session_shutdown` handler and true again inside its `session_start` one
13
+ * (RPC's `new_session` re-runs factories and emits `session_start` twice;
14
+ * the reset is idempotent).
15
+ */
16
+
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+
19
+ export function sessionAlive(pi: Pick<ExtensionAPI, "on">): () => boolean {
20
+ let alive = true;
21
+ pi.on("session_start", () => {
22
+ alive = true;
23
+ });
24
+ pi.on("session_shutdown", () => {
25
+ alive = false;
26
+ });
27
+ return () => alive;
28
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Cross-extension "a setting you own just changed on disk" channels (pure
3
+ * constants). jiti isolates module state, so when one extension writes another
4
+ * extension's setting — `/doctor preset` writing the subagent default and the
5
+ * auto-mode classifier model — it announces the write here and the owner
6
+ * re-reads its file and republishes its live status. No payload: the owner's
7
+ * own loader is the source of truth.
8
+ */
9
+
10
+ /** `subagentModel` in ~/.onecode/settings.json changed; the subagents extension re-resolves and re-emits its status. */
11
+ export const SUBAGENT_DEFAULT_CHANGED_CHANNEL = "one-code:subagent-default-changed";
12
+
13
+ /** `autoMode.classifierModel` changed; the permissions extension drops its pinned classifier and cached config. */
14
+ export const CLASSIFIER_SETTING_CHANGED_CHANNEL = "one-code:classifier-setting-changed";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shell-task tracker — a per-extension live mirror of the background bash
3
+ * shells (kind "bash") that register over TASK_REGISTER_CHANNEL.
4
+ *
5
+ * Module state does not cross extension boundaries (findings §3), but the
6
+ * task OBJECTS do: the bash extension emits each BackgroundTask over the bus,
7
+ * so any consumer holding the reference sees live status/output. Each
8
+ * interested extension (subagents' shell panel, turn-duration) instantiates
9
+ * its own tracker; multiple bus listeners are fine.
10
+ */
11
+
12
+ import { TASK_REGISTER_CHANNEL, type BackgroundTask } from "../background/registry.ts";
13
+
14
+ export interface ShellTaskTracker {
15
+ /** Every bash shell registered this session, registration order. */
16
+ list(): BackgroundTask[];
17
+ running(): BackgroundTask[];
18
+ /** Fires on register and on any tracked shell finishing. */
19
+ subscribe(listener: () => void): () => void;
20
+ }
21
+
22
+ interface EventBusLike {
23
+ events: { on(channel: string, handler: (payload: unknown) => void): unknown };
24
+ }
25
+
26
+ export function trackShellTasks(pi: EventBusLike): ShellTaskTracker {
27
+ const tasks = new Map<string, BackgroundTask>();
28
+ const listeners = new Set<() => void>();
29
+ const notify = () => {
30
+ for (const listener of listeners) {
31
+ try {
32
+ listener();
33
+ } catch {
34
+ // One consumer's render error must not starve the others.
35
+ }
36
+ }
37
+ };
38
+ pi.events.on(TASK_REGISTER_CHANNEL, (payload) => {
39
+ const task = payload as BackgroundTask | undefined;
40
+ if (!task?.id || task.kind !== "bash") return;
41
+ tasks.set(task.id, task);
42
+ notify();
43
+ // `finished` settles (never rejects) per the BackgroundTask contract.
44
+ void task.finished.then(notify);
45
+ });
46
+ return {
47
+ list: () => [...tasks.values()],
48
+ running: () => [...tasks.values()].filter((t) => t.status === "running"),
49
+ subscribe: (listener) => {
50
+ listeners.add(listener);
51
+ return () => listeners.delete(listener);
52
+ },
53
+ };
54
+ }