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
@@ -1,31 +1,127 @@
1
1
  /**
2
- * Executes a parsed workflow script body inside a fresh `node:vm` context.
2
+ * Runs a parsed workflow script body in a worker thread (`script-worker.mjs`),
3
+ * inside a `node:vm` context there. Off the TUI thread, a runaway script
4
+ * cannot freeze pi, and the worker's heartbeat lets the host kill CPU loops
5
+ * even after an await — which a vm `timeout` (synchronous prefix only) never
6
+ * could. The run's signal also stops scripts that await forever.
3
7
  *
4
- * The context gets exactly one host binding — `__workflow__`, the globals
5
- * object — and none of the host's intrinsics (the vm realm supplies its own
6
- * Math/Date/Promise/etc., which the prelude then neuters for determinism).
7
- * The vm `timeout` only bounds the synchronous prefix of execution (up to the
8
- * first await); async runaway is bounded by the agent/budget caps inside the
9
- * injected agent(), not here.
8
+ * Only agent()/workflow() RPCs and log/phase notices reach the host, through
9
+ * the gated globals. The vm context is a determinism guard for resume replay,
10
+ * NOT a security sandbox: a script can reach the worker realm through the
11
+ * prototype chain of its globals, so what it gets is the worker's process, not
12
+ * pi's — the same class of exposure as before, one thread further away.
10
13
  */
11
-
12
- import vm from "node:vm";
13
- import type { ScriptGlobals } from "./globals.ts";
14
+ import { Worker } from "node:worker_threads";
15
+ import { performance } from "node:perf_hooks";
16
+ import { MAX_AGENTS_PER_RUN, MAX_ITEMS_PER_CALL, MAX_NOTICES_PER_TICK, type ScriptGlobals } from "./globals.ts";
17
+ import { whenAborted } from "../lib/abort.ts";
14
18
  import { wrapScriptBody } from "./script-source.ts";
15
- import { WorkflowScriptError } from "./types.ts";
19
+ import { WorkflowScriptError, type AgentCallOptions } from "./types.ts";
20
+
21
+ export interface WorkflowRuntimeOptions {
22
+ signal?: AbortSignal;
23
+ /** Maximum time without a worker heartbeat, not a timeout on agent I/O. */
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ const SLOT_SPENT = 0;
28
+ const SLOT_HEARTBEAT = 1;
16
29
 
17
- const SYNC_TIMEOUT_MS = 5_000;
30
+ type WorkerMessage =
31
+ | { type: "result"; value: unknown }
32
+ | { type: "error"; message: string }
33
+ | { type: "log" | "phase"; value: string }
34
+ | { type: "call"; id: number; method: "agent"; args: [string, AgentCallOptions?] }
35
+ | { type: "call"; id: number; method: "workflow"; args: [unknown, unknown?] };
18
36
 
19
- export async function runWorkflowScript(body: string, globals: ScriptGlobals, filename = "workflow.js"): Promise<unknown> {
20
- const context = vm.createContext({ __workflow__: globals }, { codeGeneration: { strings: false, wasm: false } });
21
- let completion: unknown;
37
+ export async function runWorkflowScript(
38
+ body: string,
39
+ globals: ScriptGlobals,
40
+ filename = "workflow.js",
41
+ options: WorkflowRuntimeOptions = {},
42
+ ): Promise<unknown> {
43
+ if (options.signal?.aborted) throw new WorkflowScriptError("Workflow run was aborted");
44
+ const timeoutMs = options.timeoutMs ?? 5_000;
45
+ // Two atomic slots shared with the worker: [0] the output tokens spent so
46
+ // far (kept current here so budget.spent()/remaining() stay synchronous in
47
+ // scripts), [1] the worker's heartbeat counter (bumped by its own timer, so
48
+ // a script that starves the worker's event loop stops bumping it). Atomics,
49
+ // not messages: the host reads them on its own schedule, and a stalled host
50
+ // loop cannot misread a healthy worker.
51
+ const shared = new BigInt64Array(new SharedArrayBuffer(2 * BigInt64Array.BYTES_PER_ELEMENT));
52
+ const updateBudget = () => Atomics.store(shared, SLOT_SPENT, BigInt(Math.trunc(globals.budget.spent())));
53
+ updateBudget();
54
+ const worker = new Worker(new URL("./script-worker.mjs", import.meta.url), {
55
+ workerData: {
56
+ source: wrapScriptBody(body), filename, args: globals.args,
57
+ total: globals.budget.total, shared: shared.buffer,
58
+ maxItems: MAX_ITEMS_PER_CALL, maxPending: MAX_AGENTS_PER_RUN, maxNotices: MAX_NOTICES_PER_TICK,
59
+ },
60
+ // Do not inherit CLI test/load hooks into this plain JavaScript worker.
61
+ execArgv: [],
62
+ });
63
+ let watchdog: ReturnType<typeof setInterval> | undefined;
64
+ let unhookAbort = () => {};
22
65
  try {
23
- const script = new vm.Script(wrapScriptBody(body), { filename });
24
- completion = script.runInContext(context, { timeout: SYNC_TIMEOUT_MS });
25
- } catch (error) {
26
- throw error instanceof WorkflowScriptError ? error : new WorkflowScriptError(`Workflow script failed: ${(error as Error).message}`);
66
+ return await new Promise<unknown>((resolve, reject) => {
67
+ let stopped = false;
68
+ let lastHeartbeat = performance.now();
69
+ let lastBeat = 0n;
70
+ const fail = (message: string) => {
71
+ if (stopped) return;
72
+ stopped = true;
73
+ reject(new WorkflowScriptError(message));
74
+ };
75
+ unhookAbort = whenAborted(options.signal, () => fail("Workflow run was aborted"));
76
+ watchdog = setInterval(() => {
77
+ updateBudget();
78
+ const beat = Atomics.load(shared, SLOT_HEARTBEAT);
79
+ if (beat !== lastBeat) {
80
+ lastBeat = beat;
81
+ lastHeartbeat = performance.now();
82
+ }
83
+ // Until the first beat the worker is still starting up: allow at least 5 s.
84
+ const allowed = lastBeat === 0n ? Math.max(5_000, timeoutMs) : timeoutMs;
85
+ if (performance.now() - lastHeartbeat > allowed) {
86
+ fail(`Workflow script timed out: worker was unresponsive for ${timeoutMs}ms`);
87
+ }
88
+ }, Math.max(10, Math.min(250, timeoutMs / 4)));
89
+ const reply = async (message: Extract<WorkerMessage, { type: "call" }>) => {
90
+ try {
91
+ const value = await (message.method === "agent"
92
+ ? globals.agent(...message.args)
93
+ : globals.workflow(...message.args));
94
+ if (stopped) return;
95
+ updateBudget();
96
+ worker.postMessage({ id: message.id, value });
97
+ } catch (error) {
98
+ if (stopped) return;
99
+ worker.postMessage({ id: message.id, error: {
100
+ message: error instanceof Error ? error.message : String(error),
101
+ fatal: error instanceof WorkflowScriptError,
102
+ } });
103
+ }
104
+ };
105
+ worker.on("message", (message: WorkerMessage) => {
106
+ if (stopped) return;
107
+ try {
108
+ switch (message.type) {
109
+ case "result": stopped = true; resolve(message.value); break;
110
+ case "error": fail(`Workflow script failed: ${message.message}`); break;
111
+ case "log": globals.log(message.value); break;
112
+ case "phase": globals.phase(message.value); break;
113
+ case "call": void reply(message).catch((error) => fail(String(error))); break;
114
+ }
115
+ } catch (error) {
116
+ fail(error instanceof Error ? error.message : String(error));
117
+ }
118
+ });
119
+ worker.on("error", (error) => fail(`Workflow worker failed: ${error instanceof Error ? error.message : String(error)}`));
120
+ worker.on("exit", (code) => fail(`Workflow worker exited before returning a result (code ${code})`));
121
+ });
122
+ } finally {
123
+ clearInterval(watchdog);
124
+ unhookAbort();
125
+ await worker.terminate();
27
126
  }
28
- // The wrap's final statement is the async IIFE, so the completion value is
29
- // the promise of the script's own result (including a top-level `return`).
30
- return await Promise.resolve(completion);
31
127
  }
@@ -1,18 +1,43 @@
1
1
  /**
2
- * Live progress panel for workflow runs, rendered with the plain string[]
3
- * overload of ctx.ui.setWidget. Re-renders are debounced so a chatty fan-out
4
- * doesn't thrash the TUI; the widget clears itself once no run is active.
2
+ * Persistent workflow status strip below the editor (Claude Code's bottom
3
+ * workflow entry): one row per run — marker, name, description, right-aligned
4
+ * live stats — rendered via the component overload of ctx.ui.setWidget so it
5
+ * can right-align against the real width. Re-renders are debounced against
6
+ * chatty fan-outs; a 1s ticker keeps elapsed time moving while a run is live.
7
+ *
8
+ * The strip also carries the soft-focus state for the down-arrow flow (wired
9
+ * in index.ts): `setFocus` highlights a row, and the factory captures the TUI
10
+ * handle + the focused editor component so the input hook can verify the
11
+ * editor really has focus before stealing keys from it.
5
12
  */
6
13
 
7
14
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { linesComponent, liveUiCtx, safeThemeBold, safeThemePaint } from "../lib/tui-render.ts";
8
16
  import type { RunHandle, WorkflowRunManager } from "./run-manager.ts";
17
+ import { MAX_STATUS_ROWS, renderStatusRows, type ViewerRunSnapshot } from "./viewer.ts";
9
18
 
10
19
  const WIDGET_KEY = "workflow";
11
20
  const DEBOUNCE_MS = 250;
12
- const EVENT_TAIL = 4;
21
+
22
+ /** The slice of pi-tui's TUI the strip needs (structurally typed, no dep). */
23
+ interface TuiLike {
24
+ getFocusedComponent?(): unknown;
25
+ }
13
26
 
14
27
  export class WorkflowWidget {
15
28
  private timer: ReturnType<typeof setTimeout> | undefined;
29
+ private ticker: ReturnType<typeof setInterval> | undefined;
30
+ /** Soft-focused row index (into the newest-first snapshot list). */
31
+ focusIndex: number | undefined;
32
+ /** Captured from the widget factory; used by the focus wiring in index.ts. */
33
+ tui: TuiLike | undefined;
34
+ /**
35
+ * The core editor component, captured while it holds focus (duck-typed on
36
+ * getText — dialogs and selectors don't have it). Identity-compared before
37
+ * consuming any key, so the strip never steals input from a dialog or a
38
+ * pop-up editor.
39
+ */
40
+ editorBaseline: unknown;
16
41
 
17
42
  constructor(
18
43
  private readonly manager: WorkflowRunManager,
@@ -25,8 +50,62 @@ export class WorkflowWidget {
25
50
  this.schedule();
26
51
  }
27
52
 
53
+ /**
54
+ * Re-set the widget so it re-inserts below rows other extensions just set
55
+ * (setWidget order is last-write order); the permission-mode badge triggers
56
+ * this over the status channel to keep the strip beneath the mode line.
57
+ */
58
+ refresh(): void {
59
+ this.schedule();
60
+ }
61
+
62
+ /** Rows currently selectable (capped like the render). */
63
+ rowCount(): number {
64
+ return Math.min(this.manager.list().length, MAX_STATUS_ROWS);
65
+ }
66
+
67
+ selectedRun(): ViewerRunSnapshot | undefined {
68
+ if (this.focusIndex === undefined) return undefined;
69
+ return this.manager.snapshots(MAX_STATUS_ROWS)[this.focusIndex];
70
+ }
71
+
72
+ setFocus(index: number | undefined): void {
73
+ this.focusIndex = index;
74
+ this.render();
75
+ }
76
+
77
+ /** True when the core editor is focused and empty — safe to take the down key. */
78
+ editorFocusedAndIdle(ctx: ExtensionContext): boolean {
79
+ if (!this.editorFocused()) return false;
80
+ try {
81
+ return ctx.ui.getEditorText().trim() === "";
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** True when the core editor still holds real focus (strip may keep soft focus). */
88
+ editorFocused(): boolean {
89
+ return Boolean(this.tui && this.editorBaseline && this.tui.getFocusedComponent?.() === this.editorBaseline);
90
+ }
91
+
92
+ /**
93
+ * Set by dispose(): pi replaces the session (and this instance) on /clear,
94
+ * /new and /resume and invalidates the old ctx, while aborting runs still
95
+ * report progress for a moment — schedule()/render() must go quiet the
96
+ * instant we are disposed (same hazard as the subagent panel, SUBAGENT-REVIEW H1).
97
+ */
98
+ private disposed = false;
99
+
100
+ dispose(): void {
101
+ this.disposed = true;
102
+ if (this.timer) clearTimeout(this.timer);
103
+ if (this.ticker) clearInterval(this.ticker);
104
+ this.timer = this.ticker = undefined;
105
+ }
106
+
28
107
  private schedule(): void {
29
- if (this.timer) return;
108
+ if (this.disposed || this.timer) return;
30
109
  this.timer = setTimeout(() => {
31
110
  this.timer = undefined;
32
111
  this.render();
@@ -35,22 +114,51 @@ export class WorkflowWidget {
35
114
  }
36
115
 
37
116
  private render(): void {
38
- const ctx = this.getCtx();
39
- if (!ctx?.hasUI) return;
40
- const active = this.manager.list().filter((h) => h.status === "running");
41
- if (active.length === 0) {
117
+ const ctx = this.disposed ? undefined : liveUiCtx(this.getCtx());
118
+ if (!ctx) {
119
+ this.syncTicker([]);
120
+ return;
121
+ }
122
+ const totalRuns = this.manager.list().length;
123
+ const runs = this.manager.snapshots(MAX_STATUS_ROWS);
124
+ this.syncTicker(runs);
125
+ if (!runs.length) {
42
126
  ctx.ui.setWidget(WIDGET_KEY, undefined);
43
127
  return;
44
128
  }
45
- const lines: string[] = [];
46
- for (const handle of active) {
47
- const state = handle.state;
48
- const phase = state?.currentPhase();
49
- const stats = state ? `${state.agentCount()} agents · ${state.outputTokens()} out-tokens` : "starting…";
50
- lines.push(`⚡ workflow ${handle.meta.name} (${handle.runId}) — ${phase ? `${phase} · ` : ""}${stats}`);
51
- for (const event of handle.recentEvents.slice(-EVENT_TAIL)) lines.push(` ${event}`);
129
+ if (this.focusIndex !== undefined && this.focusIndex >= this.rowCount()) {
130
+ this.focusIndex = Math.max(0, this.rowCount() - 1);
131
+ }
132
+ const selected = this.focusIndex;
133
+ const now = Date.now();
134
+ ctx.ui.setWidget(
135
+ WIDGET_KEY,
136
+ (tui, theme) => {
137
+ this.tui = tui as TuiLike;
138
+ const focused = (tui as TuiLike).getFocusedComponent?.();
139
+ if (focused && typeof (focused as { getText?: unknown }).getText === "function") {
140
+ this.editorBaseline = focused;
141
+ }
142
+ const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
143
+ // One-space indent to align with string-array widgets (pi's Text
144
+ // wraps those with paddingX=1 — the permission-mode badge above).
145
+ return linesComponent((width) =>
146
+ renderStatusRows({ runs, totalRuns, selected, width: width - 1, now }, paint).map((line) => ` ${line}`),
147
+ );
148
+ },
149
+ { placement: "belowEditor" },
150
+ );
151
+ }
152
+
153
+ /** Keep elapsed time ticking while any run is live; stop when none is. */
154
+ private syncTicker(runs: ViewerRunSnapshot[]): void {
155
+ const active = runs.some((run) => run.status === "running");
156
+ if (active && !this.ticker && !this.disposed) {
157
+ this.ticker = setInterval(() => this.render(), 1000);
158
+ this.ticker.unref?.();
159
+ } else if (!active && this.ticker) {
160
+ clearInterval(this.ticker);
161
+ this.ticker = undefined;
52
162
  }
53
- lines.push(" /workflows to inspect · /workflows stop <runId> to stop");
54
- ctx.ui.setWidget(WIDGET_KEY, lines);
55
163
  }
56
164
  }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Worktree-session bash guards (pure).
3
+ *
4
+ * While enter_worktree isolation is active, other sessions may be working in
5
+ * the shared checkout and in sibling worktrees at the same time. Two
6
+ * invariants are enforced on bash commands BEFORE execution (and before the
7
+ * permission prompt — the guard runs in the same tool_call hook that rewrites
8
+ * inputs, which loads ahead of the permission gate):
9
+ *
10
+ * 1. Git operations must verifiably target this session's own worktree. A git
11
+ * whose repository is decided at runtime (`xargs`/`parallel`/`find -exec`
12
+ * feeding it arguments, a `-C "$DIR"` computed from a variable, a `cd`
13
+ * whose destination cannot be resolved), or that points into the shared
14
+ * checkout or a sibling worktree (`git -C`, `--git-dir`, `--work-tree`, a
15
+ * preceding `cd`), is refused with the fix named. Git against an unrelated
16
+ * repository elsewhere on disk is left alone — the permission gate and
17
+ * auto-mode still apply to it.
18
+ * 2. The stash stack is shared by the main checkout and every worktree of the
19
+ * repository, and parallel sessions can interleave on it. Stash forms that
20
+ * collide with a concurrent session (untagged push, `pop`, `clear`,
21
+ * ref-less `drop`) are refused with the tag + apply-by-SHA recipe.
22
+ *
23
+ * Steering, not security: only bash is inspected here, and an unparseable
24
+ * command is refused only when it visibly involves git (fail closed on the
25
+ * invariant, fail open on everything else).
26
+ */
27
+
28
+ import { homedir } from "node:os";
29
+ import { isWithin, toAbsolute } from "../auto-mode/paths.ts";
30
+ import { gitSubcommand, leadTokens, parseCommand, resolvePayload, type Token } from "../auto-mode/shell-analysis.ts";
31
+
32
+ export interface WorktreeGuardContext {
33
+ command: string;
34
+ /** The isolated worktree this session works in. */
35
+ worktreePath: string;
36
+ /** Root of the shared checkout the worktree belongs to. */
37
+ sharedRoot: string;
38
+ }
39
+
40
+ /** Expansion syntax the guard cannot resolve statically. */
41
+ const hasExpansion = (value: string) => /[$`]/.test(value);
42
+
43
+ const isolated = (worktreePath: string, problem: string, fix: string): string =>
44
+ `This session is isolated in the worktree ${worktreePath}, but ${problem}. ` +
45
+ `Refusing to run it — a worktree-isolated session's git operations must target its own worktree. ${fix}`;
46
+
47
+ function stashMessage(form: string, hazard: string): string {
48
+ return (
49
+ `Blocked: \`${form}\` — the git stash stack is shared between the main checkout and every worktree of this repository, and parallel sessions may push or pop entries concurrently; ${hazard}. ` +
50
+ "Prefer a temporary WIP commit on this worktree's branch to set work aside. If you must stash: " +
51
+ '`git stash push -u -m "<unique-tag>"`, capture your entry\'s SHA with `git stash list --format=\'%H %gs\'`, ' +
52
+ "restore with `git stash apply <sha>` (not pop), and drop your entry only after re-finding it by tag."
53
+ );
54
+ }
55
+
56
+ function stashReason(rest: Token[]): string | undefined {
57
+ const words = rest.map((t) => t.value);
58
+ const sub = words.find((w) => !w.startsWith("-"));
59
+ // `-m` may ride in a short-flag bundle (`git stash push -um wip`).
60
+ const tagged = words.some(
61
+ (w) => (/^-[a-zA-Z]+$/.test(w) && w.includes("m")) || w === "--message" || w.startsWith("--message="),
62
+ );
63
+ if (sub === undefined || sub === "push" || sub === "save") {
64
+ if (tagged) return undefined;
65
+ return stashMessage(
66
+ sub ? `git stash ${sub}` : "git stash",
67
+ "an untagged entry cannot be reliably re-found once other sessions push their own",
68
+ );
69
+ }
70
+ if (sub === "pop") {
71
+ return stashMessage("git stash pop", "`pop` resolves stash indexes at apply time, so it can grab or destroy another session's entry");
72
+ }
73
+ if (sub === "clear") {
74
+ return stashMessage("git stash clear", "`clear` deletes every session's stashes, not just yours");
75
+ }
76
+ if (sub === "drop") {
77
+ const ref = words.slice(words.indexOf("drop") + 1).find((w) => !w.startsWith("-"));
78
+ if (!ref) {
79
+ return stashMessage("git stash drop", "a ref-less `drop` deletes whatever is currently stash@{0}, which may be another session's entry");
80
+ }
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ export function worktreeBashGuardReason({ command, worktreePath, sharedRoot }: WorktreeGuardContext): string | undefined {
86
+ const { segments, parseFailed } = parseCommand(command);
87
+ if (parseFailed || segments.length === 0) {
88
+ if (!/\bgit\b/.test(command)) return undefined;
89
+ return isolated(
90
+ worktreePath,
91
+ "this command is too complex to verify that its git operations stay inside the worktree",
92
+ `Break it into plain, separate git commands with literal paths and run them from ${worktreePath}.`,
93
+ );
94
+ }
95
+
96
+ // Claude Code's exact refusal for stdin-fed git (one static string for
97
+ // both mechanisms) — keep it byte-identical to the capture.
98
+ const stdinFed = () =>
99
+ isolated(
100
+ worktreePath,
101
+ "this command feeds git its arguments from stdin at runtime (xargs/parallel), so the repository it targets cannot be verified",
102
+ `Run the equivalent from ${worktreePath} without the redirect.`,
103
+ );
104
+
105
+ /** Directory later segments run in; undefined = not statically known. */
106
+ let dir: string | undefined = worktreePath;
107
+ /** Subshell nesting: a `cd` inside `(...)` must not leak past the `)`. */
108
+ let depth = 0;
109
+ let dirBeforeSubshell: { dir: string | undefined } | undefined;
110
+
111
+ const checkSegment = (seg: (typeof segments)[number]): string | undefined => {
112
+ const tokens = leadTokens(seg);
113
+ const rawLead = tokens[0]?.value;
114
+ if (!rawLead) return undefined;
115
+
116
+ if (rawLead === "cd") {
117
+ // Skip cd's own options (-P/-L/-e/-@); `--` ends them; a lone `-`
118
+ // means the previous directory, which is not statically known.
119
+ let j = 1;
120
+ while (j < tokens.length && tokens[j].value.startsWith("-") && !["-", "--"].includes(tokens[j].value)) j++;
121
+ if (tokens[j]?.value === "--") j++;
122
+ const target = tokens[j]?.value;
123
+ if (!target) dir = homedir();
124
+ else if (hasExpansion(target) || target === "-") dir = undefined;
125
+ // An absolute (or ~) destination re-anchors the tracked directory
126
+ // even when it was unknown — later git commands become checkable again.
127
+ else if (target.startsWith("/") || target === "~" || target.startsWith("~/")) dir = toAbsolute("/", target, homedir());
128
+ else if (dir !== undefined) dir = toAbsolute(dir, target, homedir());
129
+ return undefined;
130
+ }
131
+ if (rawLead === "pushd" || rawLead === "popd") {
132
+ dir = undefined;
133
+ return undefined;
134
+ }
135
+
136
+ const { command: cmd, args, peeled } = resolvePayload(tokens);
137
+
138
+ // Git whose arguments are assembled at runtime — the repository it will
139
+ // target cannot be read off the command.
140
+ if (cmd === "git" && peeled.includes("xargs")) return stdinFed();
141
+ if (rawLead === "parallel" && tokens.some((t) => t.value === "git")) return stdinFed();
142
+ if (
143
+ cmd === "find" &&
144
+ args.some((a) => ["-exec", "-execdir", "-ok", "-okdir"].includes(a.value)) &&
145
+ args.some((a) => a.value === "git")
146
+ ) {
147
+ return isolated(
148
+ worktreePath,
149
+ "this command assembles git invocations at runtime (find -exec), so the repositories they target cannot be verified",
150
+ "Run the git commands directly, with explicit literal paths, instead of assembling them at runtime.",
151
+ );
152
+ }
153
+
154
+ if (cmd !== "git") return undefined;
155
+
156
+ if (dir === undefined) {
157
+ return isolated(
158
+ worktreePath,
159
+ "a preceding directory change makes the repository this git command targets unverifiable",
160
+ `Use literal paths inside ${worktreePath} instead.`,
161
+ );
162
+ }
163
+
164
+ // Effective repository target: the tracked cwd, adjusted by global flags.
165
+ let effective = dir;
166
+ const extraTargets: string[] = [];
167
+ let i = 0;
168
+ while (i < args.length) {
169
+ const value = args[i].value;
170
+ if (!value.startsWith("-")) break;
171
+ const pathFlag = value === "-C" || value === "--git-dir" || value === "--work-tree";
172
+ const inlined = value.startsWith("--git-dir=") || value.startsWith("--work-tree=");
173
+ if (pathFlag || inlined) {
174
+ const raw = inlined ? value.slice(value.indexOf("=") + 1) : args[i + 1]?.value;
175
+ if (!raw || hasExpansion(raw)) {
176
+ return isolated(
177
+ worktreePath,
178
+ `\`git ${value}\` computes its repository target at runtime, so it cannot be verified`,
179
+ `Use literal paths inside ${worktreePath} instead.`,
180
+ );
181
+ }
182
+ const resolved = toAbsolute(effective, raw, homedir());
183
+ if (value === "-C") effective = resolved;
184
+ else extraTargets.push(resolved);
185
+ i += inlined ? 1 : 2;
186
+ continue;
187
+ }
188
+ i += ["-c", "--namespace", "--exec-path", "--config-env"].includes(value) ? 2 : 1;
189
+ }
190
+
191
+ const targets = [effective, ...extraTargets];
192
+ for (const target of targets) {
193
+ if (isWithin(worktreePath, target)) continue;
194
+ if (isWithin(sharedRoot, target)) {
195
+ return isolated(
196
+ worktreePath,
197
+ `this git command targets ${target}, which is the shared checkout or another worktree of the same repository`,
198
+ `Run the equivalent against ${worktreePath}.`,
199
+ );
200
+ }
201
+ // An unrelated repository elsewhere on disk — not this guard's concern.
202
+ }
203
+
204
+ // The stash stack is per-repository and shared across its worktrees. Any
205
+ // shared-root target that is NOT inside the worktree already returned
206
+ // above, so only worktree-contained targets can reach this check.
207
+ if (targets.some((t) => isWithin(worktreePath, t))) {
208
+ const { sub, rest } = gitSubcommand(args);
209
+ if (sub === "stash") {
210
+ const reason = stashReason(rest);
211
+ if (reason) return reason;
212
+ }
213
+ }
214
+ return undefined;
215
+ };
216
+
217
+ for (const seg of segments) {
218
+ // Subshell bookkeeping reads the RAW tokens — leadTokens strips the very
219
+ // parens being counted. A `cd` between `(` and `)` is scoped: the tracked
220
+ // directory is restored when the subshell closes.
221
+ const first = seg.tokens[0]?.value ?? "";
222
+ const opens = (/^\(+/.exec(first)?.[0] ?? "").length;
223
+ if (opens > 0 && depth === 0) dirBeforeSubshell = { dir };
224
+ depth += opens;
225
+
226
+ const reason = checkSegment(seg);
227
+ if (reason) return reason;
228
+
229
+ const last = seg.tokens[seg.tokens.length - 1]?.value ?? "";
230
+ const closes = (/\)+$/.exec(last)?.[0] ?? "").length;
231
+ if (closes > 0 && depth > 0) {
232
+ depth = Math.max(0, depth - closes);
233
+ if (depth === 0 && dirBeforeSubshell) {
234
+ dir = dirBeforeSubshell.dir;
235
+ dirBeforeSubshell = undefined;
236
+ }
237
+ }
238
+ }
239
+ return undefined;
240
+ }
@@ -20,6 +20,8 @@ import { Type } from "typebox";
20
20
  import { restoreLatestDetails } from "../lib/branch-restore.ts";
21
21
  import { DEFER_CHANNEL } from "../lib/deferred.ts";
22
22
  import { REMINDER_CHANNEL } from "../lib/reminders.ts";
23
+ import { worktreeBashGuardReason } from "./guards.ts";
24
+ import { ORIGINAL_COMMAND_CHANNEL, type OriginalCommandRecord } from "../lib/original-command.ts";
23
25
  import { rewriteToolInput, validateWorktreeName } from "./rewrite.ts";
24
26
  import { ccToolRenderers } from "../lib/tui-render.ts";
25
27
 
@@ -33,6 +35,8 @@ interface WorktreeState {
33
35
  baseCommit?: string;
34
36
  createdByUs: boolean;
35
37
  originalCwd: string;
38
+ /** Root of the shared checkout (`git rev-parse --show-toplevel` at entry). */
39
+ sharedRoot: string;
36
40
  }
37
41
 
38
42
  interface WorktreeDetails {
@@ -64,7 +68,12 @@ export default function worktreeExtension(pi: ExtensionAPI) {
64
68
  const applyState = (next: WorktreeState | undefined) => {
65
69
  state = next;
66
70
  if (next) {
67
- pi.events.emit(REMINDER_CHANNEL, { text: reminderFor(next), scope: "every-turn", key: REMINDER_KEY });
71
+ pi.events.emit(REMINDER_CHANNEL, {
72
+ text: reminderFor(next),
73
+ scope: "every-turn",
74
+ key: REMINDER_KEY,
75
+ placement: "sticky-append",
76
+ });
68
77
  } else {
69
78
  pi.events.emit(REMINDER_CHANNEL, { key: REMINDER_KEY, remove: true });
70
79
  }
@@ -74,6 +83,8 @@ export default function worktreeExtension(pi: ExtensionAPI) {
74
83
  const details = restoreLatestDetails<WorktreeDetails>(ctx.sessionManager.getBranch(), WORKTREE_TOOLS, (d) => d?.worktreeState !== undefined);
75
84
  let restored = details?.worktreeState ?? undefined;
76
85
  if (restored && !existsSync(restored.path)) restored = undefined;
86
+ // Sessions persisted before sharedRoot existed: originalCwd is the best guess.
87
+ if (restored && !restored.sharedRoot) restored = { ...restored, sharedRoot: restored.originalCwd };
77
88
  applyState(restored);
78
89
  };
79
90
 
@@ -82,8 +93,22 @@ export default function worktreeExtension(pi: ExtensionAPI) {
82
93
 
83
94
  pi.on("tool_call", (event) => {
84
95
  if (!state) return;
85
- if (["enter_worktree", "exit_worktree", "subagent", "send_message", "workflow"].includes(event.toolName)) return;
86
- rewriteToolInput(event.toolName, event.input as Record<string, unknown>, state.path);
96
+ if (["enter_worktree", "exit_worktree", "Agent", "SendMessage", "workflow"].includes(event.toolName)) return;
97
+ if (event.toolName === "bash") {
98
+ // Guard before rewriting (and before the permission prompt — this
99
+ // extension loads ahead of permissions): git must verifiably target
100
+ // this worktree, and shared-stash footguns are refused with the recipe.
101
+ const command = (event.input as Record<string, unknown>).command;
102
+ if (typeof command === "string") {
103
+ const reason = worktreeBashGuardReason({ command, worktreePath: state.path, sharedRoot: state.sharedRoot });
104
+ if (reason) return { block: true, reason };
105
+ }
106
+ }
107
+ const { originalCommand } = rewriteToolInput(event.toolName, event.input as Record<string, unknown>, state.path);
108
+ if (originalCommand !== undefined) {
109
+ const record: OriginalCommandRecord = { toolCallId: event.toolCallId, command: originalCommand, cwd: state.path };
110
+ pi.events.emit(ORIGINAL_COMMAND_CHANNEL, record);
111
+ }
87
112
  });
88
113
 
89
114
  pi.registerTool({
@@ -120,7 +145,7 @@ export default function worktreeExtension(pi: ExtensionAPI) {
120
145
  const known = await listWorktreePaths(ctx.cwd);
121
146
  const target = known.find((p) => p === params.path);
122
147
  if (!target) return fail(`${params.path} is not a worktree of this repository. Known worktrees:\n${known.join("\n")}`);
123
- const next: WorktreeState = { path: target, createdByUs: false, originalCwd: ctx.cwd };
148
+ const next: WorktreeState = { path: target, createdByUs: false, originalCwd: ctx.cwd, sharedRoot: repoRoot };
124
149
  applyState(next);
125
150
  return {
126
151
  content: [{ type: "text", text: `Switched into existing worktree ${target}. All work now happens there; exit_worktree returns to ${ctx.cwd}.` }],
@@ -157,7 +182,7 @@ export default function worktreeExtension(pi: ExtensionAPI) {
157
182
  return fail(`Could not create worktree: ${(error as Error).message}`);
158
183
  }
159
184
 
160
- const next: WorktreeState = { path, branch, baseCommit, createdByUs: true, originalCwd: ctx.cwd };
185
+ const next: WorktreeState = { path, branch, baseCommit, createdByUs: true, originalCwd: ctx.cwd, sharedRoot: repoRoot };
161
186
  applyState(next);
162
187
  return {
163
188
  content: [