one-code-extension 0.1.4 → 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 -217
  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
@@ -7,7 +7,13 @@
7
7
  * approved, or confirmed something — especially mid-task, where a pending
8
8
  * question plus an arriving notification looks like an answer.
9
9
  */
10
- /** First line of the framing — `tui-render.ts`'s `notificationBody` matches on this to strip it back off for display. */
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { RunOutcomeLatch } from "./interrupt.ts";
12
+
13
+ /** pi's session-mode union, derived from the context (the package root does not export ExtensionMode itself). */
14
+ type ExtensionMode = ExtensionContext["mode"];
15
+
16
+ /** First line of the framing — `notificationBody` matches on this to strip it back off for display. */
11
17
  export const NOTIFICATION_HEADER = "SYSTEM NOTIFICATION — NOT USER INPUT";
12
18
  /** Second line of the framing — likewise matched by `notificationBody`. */
13
19
  export const NOTIFICATION_PREFIX =
@@ -16,3 +22,316 @@ export const NOTIFICATION_PREFIX =
16
22
  export function systemNotification(body: string): string {
17
23
  return [NOTIFICATION_HEADER, NOTIFICATION_PREFIX, "", body].join("\n");
18
24
  }
25
+
26
+ /** `details` key carrying a notification's outbox id, so delivery can be matched on `message_end`. */
27
+ export const NOTIFICATION_ID_KEY = "notificationId";
28
+ /** `details` key on a coalesced message: the `{customType, details}` of each notification it carries, in arrival order. */
29
+ export const NOTIFICATION_BATCH_KEY = "notifications";
30
+
31
+ /**
32
+ * Strip the `systemNotification` framing back off a notification's text. Used
33
+ * by the transcript renderer (the framing is model-only) and by the notifier
34
+ * when it merges several notifications under one frame. Unframed text is
35
+ * returned trimmed.
36
+ */
37
+ export function notificationBody(text: string): string {
38
+ const lines = text.split("\n");
39
+ if (lines[0]?.startsWith(NOTIFICATION_HEADER)) {
40
+ let i = 1;
41
+ while (i < lines.length && (lines[i].trim() === "" || lines[i].startsWith(NOTIFICATION_PREFIX))) i++;
42
+ return lines.slice(i).join("\n").trim();
43
+ }
44
+ return text.trim();
45
+ }
46
+
47
+ /**
48
+ * One message carrying several notifications that arrived together: a single
49
+ * frame, a lead line saying how many, and each body under its own divider, in
50
+ * arrival order. Claude Code delivers all pending task notifications in one
51
+ * attachment round; pi's steering queue drains one message per LLM call, so
52
+ * merging is the only way N completions cost one call rather than N.
53
+ */
54
+ export function mergeNotificationTexts(texts: string[]): string {
55
+ if (texts.length === 1) return texts[0];
56
+ const n = texts.length;
57
+ const parts = texts.map((text, i) => `--- Event ${i + 1} of ${n} ---\n${notificationBody(text)}`);
58
+ return systemNotification([`${n} events arrived together; each one is listed below.`, ...parts].join("\n\n"));
59
+ }
60
+
61
+ /** Inject a harness notification (a task/agent completion, a monitor event, a review note) into the conversation. */
62
+ export type TaskNotifier = (customType: string, text: string, details?: Record<string, unknown>) => void;
63
+
64
+ /** The ExtensionAPI slice the notifier uses. */
65
+ export type TaskNotifierApi = Pick<ExtensionAPI, "sendMessage" | "sendUserMessage" | "on">;
66
+
67
+ export interface TaskNotifierOptions {
68
+ /**
69
+ * Notifications arriving within this many milliseconds of the first one are
70
+ * merged into one message (see `mergeNotificationTexts`). Claude Code batches
71
+ * within 200 ms; 250 covers "finished together" (parallel agents, a fan-out of
72
+ * background commands) without a perceptible delay. `0` dispatches
73
+ * synchronously with no merging.
74
+ */
75
+ coalesceMs?: number;
76
+ }
77
+
78
+ /** Default coalescing window (ms). */
79
+ export const DEFAULT_COALESCE_MS = 250;
80
+
81
+ /**
82
+ * Build the notifier for one extension. Call once at factory scope: it
83
+ * registers the `message_end` / `agent_end` / `agent_settled` /
84
+ * `session_start` handlers that make delivery reliable.
85
+ *
86
+ * Delivery policy, written once for every producer: **steered, not a
87
+ * follow-up** — mid-turn the message lands after the current tool batch, so
88
+ * the model can act on a finished task while other work continues (Claude
89
+ * Code's task notifications arrive the same way); a followUp would wait for
90
+ * the whole turn and drain one per settle. When the session is idle,
91
+ * `triggerTurn` starts a turn. The one deliberate exception is a message sent
92
+ * FROM `agent_settled` (the hooks extension), where the turn is already over
93
+ * and only followUp semantics exist.
94
+ *
95
+ * Coalescing: pi drains its steering queue one message per LLM call
96
+ * (`steeringMode` defaults to `one-at-a-time`, a user setting that also
97
+ * governs their own typed steers, so it is not ours to flip), and three
98
+ * background completions arriving in the same second cost three model calls
99
+ * and three replies (STEERING-REVIEW-2026-09-05 M1, measured). Notifications
100
+ * that arrive within `coalesceMs` of each other are therefore merged into one
101
+ * custom message — one frame, N bodies — the way Claude Code delivers all
102
+ * pending task notifications in one round.
103
+ *
104
+ * Delivery guarantee: pi's steering queue is not durable. Esc aborts the turn
105
+ * by clearing BOTH queues and restoring only the user's own queued text to the
106
+ * editor, so a completion steered in mid-turn and not yet drained would vanish
107
+ * — the task reads "completed" while the model never hears of it. Each
108
+ * notification therefore sits in an outbox until pi emits `message_end` for
109
+ * the custom message carrying its id. pi drains every queued steer before it
110
+ * settles (its post-run loop continues while messages are queued), so anything
111
+ * still pending at `agent_settled` was discarded; the session is idle by then,
112
+ * so a re-send starts a fresh turn (the same "idle → new turn" path a
113
+ * notification takes when nothing is running). One re-send per notification:
114
+ * if the second copy is not confirmed either, the entry is dropped rather than
115
+ * looping a turn per settle.
116
+ *
117
+ * After an interrupt, notifications wait for the user. A turn the user stopped
118
+ * with Esc settles `aborted`; re-sending its discarded notification with
119
+ * `triggerTurn` restarted, within milliseconds, the very work the user had just
120
+ * interrupted (review M2). So once a turn has settled `aborted` or `error`
121
+ * (a turn that died on a provider error should not be restarted by a
122
+ * notification either), every notification — the re-sends and
123
+ * any new one arriving while idle — is delivered as `nextTurn`: pi holds it
124
+ * and appends it to the user's next prompt (`_pendingNextTurnMessages`),
125
+ * exactly Claude Code's behaviour after an interrupt. The hold ends at the
126
+ * next `before_agent_start`, i.e. the next prompt the user actually sends.
127
+ *
128
+ * Dead sessions: a producer's callback can outlive the session it belongs to
129
+ * (a background shell finishing after `/clear` replaced the session, or after
130
+ * a `-p` run settled and pi disposed it). pi's extension API then throws from
131
+ * `assertActive`, and from a stream/child-process callback that is an uncaught
132
+ * exception — it took the whole process down (STEERING-REVIEW-2026-09-05 H3,
133
+ * measured both ways). The notifier goes inert on `session_shutdown` and drops
134
+ * anything dispatched afterwards; a throwing `sendMessage` is swallowed as a
135
+ * second line.
136
+ *
137
+ * First turn of a session: pi's idle `sendMessage(…, {triggerTurn: true})`
138
+ * calls `_runAgentPrompt` directly and skips the `prompt()` preamble, so no
139
+ * `before_agent_start` fires — and our system prompt is set from that hook.
140
+ * A session whose first input was `/loop …` therefore ran its first request on
141
+ * pi's stock system prompt with no reminder stack (review H1, measured). Until
142
+ * no `prompt()` has run in this process, an idle notification is delivered as a
143
+ * user message (`sendUserMessage`, source "extension"), which takes the full
144
+ * prompt path; the cost is that this one notification renders as a user bubble.
145
+ * Every later notification goes the custom-message way. Upstream ask:
146
+ * docs/upstream_prs.md #17.
147
+ */
148
+ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOptions = {}): TaskNotifier {
149
+ interface Pending {
150
+ customType: string;
151
+ text: string;
152
+ details: Record<string, unknown>;
153
+ resent: boolean;
154
+ }
155
+ interface Incoming {
156
+ customType: string;
157
+ text: string;
158
+ details: Record<string, unknown>;
159
+ }
160
+ const coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS;
161
+ const pending = new Map<string, Pending>();
162
+ let seq = 0;
163
+ /** False once the session this notifier belongs to has shut down. */
164
+ let active = true;
165
+ /** True once `prompt()` has run in this process (the only path that emits before_agent_start). */
166
+ let prompted = false;
167
+ /** True between agent_start and agent_settled. */
168
+ let busy = false;
169
+ /** True from a turn settling aborted/errored until the user's next prompt: hold, do not start turns. */
170
+ let interrupted = false;
171
+ const outcome = new RunOutcomeLatch();
172
+ /** Notifications waiting for the coalescing window to close. */
173
+ let batch: Incoming[] = [];
174
+ let batchTimer: ReturnType<typeof setTimeout> | undefined;
175
+
176
+ const dispatch = (id: string, entry: Pending) => {
177
+ if (!active) {
178
+ pending.delete(id);
179
+ return;
180
+ }
181
+ try {
182
+ if (interrupted && !busy) {
183
+ // Held for the user's next prompt (see the header). pi keeps a nextTurn
184
+ // message on the session itself, out of reach of Esc's queue clearing,
185
+ // so it counts as delivered now: a re-send at a later settle would
186
+ // duplicate it.
187
+ pending.delete(id);
188
+ pi.sendMessage(
189
+ {
190
+ customType: entry.customType,
191
+ content: [{ type: "text", text: entry.text }],
192
+ display: true,
193
+ details: { ...entry.details, [NOTIFICATION_ID_KEY]: id },
194
+ },
195
+ { deliverAs: "nextTurn" },
196
+ );
197
+ return;
198
+ }
199
+ if (!prompted && !busy) {
200
+ // No confirmation possible for a user-role message (no details), and
201
+ // prompt() cannot be cleared by Esc before it starts: count it delivered.
202
+ pending.delete(id);
203
+ pi.sendUserMessage(entry.text);
204
+ return;
205
+ }
206
+ pi.sendMessage(
207
+ {
208
+ customType: entry.customType,
209
+ content: [{ type: "text", text: entry.text }],
210
+ display: true,
211
+ details: { ...entry.details, [NOTIFICATION_ID_KEY]: id },
212
+ },
213
+ { deliverAs: "steer", triggerTurn: true },
214
+ );
215
+ } catch {
216
+ // The session was disposed under us (assertActive). Dropping the notice
217
+ // beats taking the process down from a stream callback.
218
+ pending.delete(id);
219
+ }
220
+ };
221
+
222
+ /** Close the coalescing window: one outbox entry for everything that arrived in it. */
223
+ const flush = () => {
224
+ batchTimer = undefined;
225
+ const incoming = batch;
226
+ batch = [];
227
+ if (incoming.length === 0) return;
228
+ const id = `${++seq}-${Date.now().toString(36)}`;
229
+ const entry: Pending =
230
+ incoming.length === 1
231
+ ? { ...incoming[0], resent: false }
232
+ : {
233
+ customType: incoming[0].customType,
234
+ text: mergeNotificationTexts(incoming.map((n) => n.text)),
235
+ details: { [NOTIFICATION_BATCH_KEY]: incoming.map((n) => ({ customType: n.customType, details: n.details })) },
236
+ resent: false,
237
+ };
238
+ pending.set(id, entry);
239
+ dispatch(id, entry);
240
+ };
241
+ const clearBatch = () => {
242
+ if (batchTimer !== undefined) clearTimeout(batchTimer);
243
+ batchTimer = undefined;
244
+ batch = [];
245
+ };
246
+
247
+ pi.on("message_end", (event) => {
248
+ const message = event.message as { role?: string; details?: unknown };
249
+ if (message.role !== "custom") return;
250
+ const id = (message.details as Record<string, unknown> | undefined)?.[NOTIFICATION_ID_KEY];
251
+ if (typeof id === "string") pending.delete(id);
252
+ });
253
+ pi.on("agent_end", (event, ctx) => {
254
+ outcome.record(event.messages, ctx.signal?.aborted);
255
+ });
256
+ pi.on("agent_settled", () => {
257
+ busy = false;
258
+ const settled = outcome.take();
259
+ if (settled === "aborted" || settled === "error") interrupted = true;
260
+ for (const [id, entry] of [...pending]) {
261
+ if (entry.resent) {
262
+ pending.delete(id);
263
+ continue;
264
+ }
265
+ entry.resent = true;
266
+ dispatch(id, entry);
267
+ }
268
+ });
269
+ pi.on("agent_start", () => {
270
+ busy = true;
271
+ });
272
+ pi.on("before_agent_start", () => {
273
+ prompted = true;
274
+ interrupted = false;
275
+ });
276
+ // A replaced session (/clear, /new, resume) has no use for the old one's undelivered notices.
277
+ pi.on("session_start", () => {
278
+ pending.clear();
279
+ clearBatch();
280
+ active = true;
281
+ prompted = false;
282
+ busy = false;
283
+ interrupted = false;
284
+ });
285
+ pi.on("session_shutdown", () => {
286
+ active = false;
287
+ clearBatch();
288
+ });
289
+
290
+ return (customType, text, details = {}) => {
291
+ if (!active) return;
292
+ batch.push({ customType, text, details });
293
+ if (coalesceMs <= 0) {
294
+ flush();
295
+ return;
296
+ }
297
+ if (batchTimer === undefined) {
298
+ batchTimer = setTimeout(flush, coalesceMs);
299
+ (batchTimer as { unref?: () => void }).unref?.();
300
+ }
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Whether this session survives past the current turn, i.e. whether a
306
+ * notification queued for later can ever be delivered. One-shot modes
307
+ * (`-p` prints, `--mode json`) dispose the session as soon as the prompt
308
+ * settles, so work that reports back via a task notifier must run
309
+ * blocking there instead of detaching. Exhaustive over pi's ExtensionMode,
310
+ * so a mode pi adds later fails the typecheck here instead of silently
311
+ * falling into either branch.
312
+ */
313
+ export function sessionOutlivesTurn(mode: ExtensionMode): boolean {
314
+ switch (mode) {
315
+ case "tui":
316
+ case "rpc":
317
+ return true;
318
+ case "print":
319
+ case "json":
320
+ return false;
321
+ default:
322
+ return assertNever(mode);
323
+ }
324
+ }
325
+
326
+ function assertNever(mode: never): never {
327
+ throw new Error(`Unhandled session mode: ${String(mode)}`);
328
+ }
329
+
330
+ /**
331
+ * The sentence a tool adds to its result when `sessionOutlivesTurn` was false
332
+ * and it ran its normally-detached work to completion instead. One wording for
333
+ * bash, monitor and workflow, so the model reads the same rule everywhere.
334
+ */
335
+ export function oneShotNote(what: string): string {
336
+ return `This is a one-shot session, so the ${what} ran to completion instead of in the background.`;
337
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * One Code's own settings files — the writable half of "own state, borrowed
3
+ * config" (docs/decisions/memory-state.md). `~/.claude` is Claude Code's
4
+ * directory and One Code only ever *reads* it; anything One Code persists that
5
+ * looks like a settings key (its own `subagentModel` / `autoMode.classifierModel`,
6
+ * the allow rules `/allow` records) lands here instead, so One Code never mutates
7
+ * Claude Code's config and a value that means nothing to Claude Code (a model it
8
+ * cannot run, say) never ends up in Claude Code's file.
9
+ *
10
+ * Two scopes, mirroring the memory layout so a repo's worktrees and
11
+ * subdirectories share one file:
12
+ * ~/.onecode/settings.json (user, global)
13
+ * ~/.onecode/projects/<slug>/settings.json (per git repo, else cwd)
14
+ *
15
+ * The state root is resolved against an explicit `home` (not `os.homedir()`)
16
+ * so callers that already thread `home` — the auto-mode and subagent settings
17
+ * loaders — stay hermetic under a temp home in tests. `ONECODE_STATE_DIR` is
18
+ * honoured exactly as `oneCodeStateDir()` does; when it is unset the root is
19
+ * `<home>/.onecode`, which equals `oneCodeStateDir()` in production where
20
+ * `home === os.homedir()`.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { writeJsonAtomic } from "./atomic-write.ts";
26
+ import { findProjectRoot } from "./git.ts";
27
+ import { projectSlug } from "./memory.ts";
28
+ import { oneCodeStateDir } from "./paths.ts";
29
+
30
+ /** `~/.onecode/settings.json` — One Code's user-scope settings (writable). */
31
+ export function oneCodeSettingsPath(home: string, env: NodeJS.ProcessEnv = process.env): string {
32
+ return join(oneCodeStateDir(env, home), "settings.json");
33
+ }
34
+
35
+ /**
36
+ * `~/.onecode/projects/<slug>/settings.json` — One Code's per-repo settings
37
+ * (writable). Keyed by the git repository root when there is one (shared by
38
+ * worktrees and subdirectories), else the cwd — the same slug the memory dir uses.
39
+ */
40
+ export function oneCodeProjectSettingsPath(cwd: string, home: string, env: NodeJS.ProcessEnv = process.env): string {
41
+ return join(oneCodeStateDir(env, home), "projects", projectSlug(findProjectRoot(cwd) ?? cwd), "settings.json");
42
+ }
43
+
44
+ /**
45
+ * Read a JSON settings object for a read-modify-write. Throws on a malformed
46
+ * file rather than returning `{}`: a lenient read merely skips a setting, but a
47
+ * lenient write would replace the whole file with only the caller's keys.
48
+ * A missing file is an empty object (the writer creates it).
49
+ */
50
+ export function readSettingsForWrite(path: string): Record<string, unknown> {
51
+ if (!existsSync(path)) return {};
52
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
53
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
54
+ throw new Error(`${path}: root must be a JSON object`);
55
+ }
56
+ return parsed as Record<string, unknown>;
57
+ }
58
+
59
+ /** Write a settings object atomically (temp sibling + rename), creating parent
60
+ * directories as needed — a reader never sees a half-written settings file. */
61
+ export function writeSettings(path: string, file: Record<string, unknown>): void {
62
+ writeJsonAtomic(path, file);
63
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Side channel for a bash call's ORIGINAL command in a worktree session.
3
+ *
4
+ * pi binds tools to the cwd captured at session creation, so the worktree
5
+ * extension cd-wraps every bash command on `tool_call`. The permission
6
+ * matcher, the classifier transcript, and bash's own guards must still see
7
+ * what the model actually asked for — but that fact must never travel inside
8
+ * `event.input`: pi validates tool arguments without `additionalProperties:
9
+ * false`, so a key the model itself writes there survives to `tool_call`, and
10
+ * a gate that read it would be matching rules against a string the model
11
+ * chose (a fake `npm test` fronting a real `curl … | sh`). The wrapper
12
+ * publishes the original over the bus keyed by pi's `toolCallId` — a value
13
+ * the model cannot forge — and consumers look it up by that id. Each
14
+ * consuming extension owns its own store (module state does not cross
15
+ * extension boundaries, findings §3); multiple bus listeners are fine.
16
+ */
17
+
18
+ /** `{ toolCallId, command, cwd }` — the pre-wrapper command of one bash call and the directory it runs in. */
19
+ export const ORIGINAL_COMMAND_CHANNEL = "one-code:bash-original-command";
20
+
21
+ export interface OriginalCommandRecord {
22
+ toolCallId: string;
23
+ command: string;
24
+ /**
25
+ * The directory the wrapper `cd`s into (the worktree). The pre-gate and the
26
+ * recoverability judge run on the original command against THIS cwd, so a
27
+ * worktree session keeps the containment fast path and the prompt shows the
28
+ * command the model wrote, not `cd '…' && (…)` (PERMISSIONS-REVIEW-2026-09-05 L3).
29
+ */
30
+ cwd?: string;
31
+ }
32
+
33
+ /** Bounded so a long session cannot grow the map without limit. */
34
+ const MAX_ENTRIES = 256;
35
+
36
+ interface EventBusLike {
37
+ events: { on(channel: string, handler: (payload: unknown) => void): unknown };
38
+ }
39
+
40
+ /** The published original of one call: the command and, when known, the directory it runs in. */
41
+ export interface OriginalCommand {
42
+ command: string;
43
+ cwd?: string;
44
+ }
45
+
46
+ export interface OriginalCommandStore {
47
+ /** The model's original command (and its cwd) for this call id, when a wrapper published one. */
48
+ get(toolCallId: string): OriginalCommand | undefined;
49
+ }
50
+
51
+ export function trackOriginalCommands(pi: EventBusLike): OriginalCommandStore {
52
+ const byId = new Map<string, OriginalCommand>();
53
+ pi.events.on(ORIGINAL_COMMAND_CHANNEL, (payload) => {
54
+ const record = payload as Partial<OriginalCommandRecord> | undefined;
55
+ if (typeof record?.toolCallId !== "string" || typeof record.command !== "string") return;
56
+ byId.set(record.toolCallId, { command: record.command, cwd: typeof record.cwd === "string" ? record.cwd : undefined });
57
+ if (byId.size > MAX_ENTRIES) {
58
+ const oldest = byId.keys().next().value;
59
+ if (oldest !== undefined) byId.delete(oldest);
60
+ }
61
+ });
62
+ return { get: (toolCallId) => byId.get(toolCallId) };
63
+ }
64
+
65
+ /**
66
+ * The command the gate and guards should evaluate: the published original
67
+ * when this call was wrapped, otherwise the command as sent.
68
+ */
69
+ export function commandToEvaluate(store: OriginalCommandStore, toolCallId: string, command: string): string {
70
+ return store.get(toolCallId)?.command ?? command;
71
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Reads the one-code-extension package version from its own package.json so the
3
+ * banner never hardcodes (and drifts from) the released version.
4
+ *
5
+ * The bundled app sets CC_VERSION to the app package version, which is kept in
6
+ * lockstep with this package (docs/decisions/distribution.md). When the
7
+ * extension is loaded directly via plain `pi` there is no CC_VERSION, so this
8
+ * reads the extension's own package.json instead of falling back to a stale
9
+ * literal.
10
+ */
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+
16
+ /**
17
+ * Version from the extension's package.json (repo root, two levels above this
18
+ * lib file). Returns "0.0.0" only if the file is somehow unreadable, so a bad
19
+ * read is visibly wrong rather than silently masquerading as a real release.
20
+ */
21
+ export function extensionVersion(): string {
22
+ try {
23
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
24
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
25
+ version?: unknown;
26
+ };
27
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
28
+ } catch {
29
+ return "0.0.0";
30
+ }
31
+ }
@@ -5,7 +5,7 @@
5
5
  * directory and One Code treats it as a read-only compatibility surface
6
6
  * (settings, skills, agents, plugins, CLAUDE.md); everything One Code
7
7
  * *generates* — plan files, and over time the rest of its state — lands in
8
- * One Code's own `~/.one-code`, so neither product's artifacts mingle with the
8
+ * One Code's own `~/.onecode`, so neither product's artifacts mingle with the
9
9
  * other's. See "Own state, borrowed config" in docs/decisions.md.
10
10
  */
11
11
 
@@ -20,7 +20,42 @@ export function claudeConfigDir(env: Record<string, string | undefined> = proces
20
20
  return env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
21
21
  }
22
22
 
23
- /** One Code's own state dir — everything One Code generates goes here. */
24
- export function oneCodeStateDir(env: Record<string, string | undefined> = process.env): string {
25
- return env.ONE_CODE_STATE_DIR || join(homedir(), ".one-code");
23
+ /**
24
+ * The user-scope Claude Code dir for a given `home` — `claudeConfigDir` for
25
+ * callers that thread a `home` (tests, `~`-expansion). `CLAUDE_CONFIG_DIR`
26
+ * still wins, exactly as Claude Code honours it; project-scope `<cwd>/.claude`
27
+ * is never relocated by that variable, so callers keep spelling that one out.
28
+ */
29
+ export function claudeUserDir(home: string, env: Record<string, string | undefined> = process.env): string {
30
+ return env.CLAUDE_CONFIG_DIR || join(home, ".claude");
31
+ }
32
+
33
+ /**
34
+ * Claude Code's `.claude.json` (MCP servers, onboarding state): beside the
35
+ * home dir by default, and INSIDE `CLAUDE_CONFIG_DIR` when that is set — the
36
+ * one user-scope file that does not live under `~/.claude`.
37
+ */
38
+ export function claudeJsonPath(home: string, env: Record<string, string | undefined> = process.env): string {
39
+ return env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, ".claude.json") : join(home, ".claude.json");
40
+ }
41
+
42
+ /**
43
+ * Shell-style `~` expansion with one semantics everywhere: a bare `~` is
44
+ * `home`, `~/x` is under it, and `~user` is left alone (no other user's home is
45
+ * ever guessed). Anything else is returned unchanged.
46
+ */
47
+ export function expandTilde(path: string, home: string): string {
48
+ if (path === "~") return home;
49
+ if (path.startsWith("~/")) return join(home, path.slice(2));
50
+ return path;
51
+ }
52
+
53
+ /**
54
+ * One Code's own state dir — everything One Code generates goes here. `home`
55
+ * defaults to the real home; callers that already thread a `home` (the settings
56
+ * loaders, hermetic in tests) pass it so the root stays under that home when
57
+ * `ONECODE_STATE_DIR` is unset.
58
+ */
59
+ export function oneCodeStateDir(env: Record<string, string | undefined> = process.env, home: string = homedir()): string {
60
+ return env.ONECODE_STATE_DIR || join(home, ".onecode");
26
61
  }