@gavin-hu/my-pi-agent 0.18.0 → 0.20.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 (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +561 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -12,11 +12,24 @@
12
12
  * 5. the un-re-rooted `powershell` tool, which has no guard of its own.
13
13
  */
14
14
 
15
- import { homedir } from "node:os";
16
- import { isAbsolute, join, resolve } from "node:path";
15
+ import { isAbsolute, resolve } from "node:path";
17
16
  import { isInside, isInsideReal, realPathOfNearest } from "../../lib/path.ts";
18
- import { isShellEscapable } from "../../lib/shell.ts";
17
+ import { WORKSPACE_READERS } from "../../lib/policy.ts";
19
18
  import type { WorktreeConfig } from "./config.ts";
19
+ import {
20
+ COMMAND_WRAPPERS,
21
+ isAssignment,
22
+ isUnverifiable,
23
+ pathEscapes,
24
+ redirectionTargets,
25
+ splitSegments,
26
+ type ShellWord,
27
+ tokenize,
28
+ } from "./shell.ts";
29
+
30
+ // The reader's public names, so a caller keeps importing the guards from here.
31
+ export { isUnverifiable };
32
+ export type { Redirect, ShellWord } from "./shell.ts";
20
33
 
21
34
  export interface GuardBlock {
22
35
  block: true;
@@ -33,14 +46,11 @@ export function resolveUnder(root: string, path: string | undefined): string {
33
46
  // File tools (check 1)
34
47
  // ---------------------------------------------------------------------------
35
48
 
49
+ /** The file tools and the one argument that carries their path. */
36
50
  const PATH_FIELD: Record<string, string> = {
37
- read: "path",
38
- read_doc: "path",
51
+ ...Object.fromEntries(WORKSPACE_READERS.map((name): [string, string] => [name, "path"])),
39
52
  write: "path",
40
53
  edit: "path",
41
- grep: "path",
42
- find: "path",
43
- ls: "path",
44
54
  };
45
55
 
46
56
  const WRITE_TOOLS = new Set(["write", "edit"]);
@@ -82,281 +92,6 @@ export function guardFileTool(
82
92
  return undefined;
83
93
  }
84
94
 
85
- // ---------------------------------------------------------------------------
86
- // Shell commands (checks 2-4)
87
- // ---------------------------------------------------------------------------
88
-
89
- /** One shell word, with whether it began inside a quote (a quoted literal). */
90
- interface ShellWord {
91
- text: string;
92
- leadingQuoted: boolean;
93
- }
94
-
95
- /** Split a command on unquoted `;`, `&`, `|`, and newlines, preserving quotes. */
96
- function splitSegments(command: string): string[] {
97
- const segments: string[] = [];
98
- let current = "";
99
- let quote: string | null = null;
100
- for (let i = 0; i < command.length; i++) {
101
- const ch = command[i];
102
- if (quote) {
103
- current += ch;
104
- if (ch === "\\" && quote === '"' && i + 1 < command.length) {
105
- const next = command[i + 1];
106
- if (isShellEscapable(next)) current += command[++i];
107
- } else if (ch === quote) {
108
- quote = null;
109
- }
110
- continue;
111
- }
112
- if (ch === "'" || ch === '"') {
113
- quote = ch;
114
- current += ch;
115
- } else if (ch === ";" || ch === "\n") {
116
- segments.push(current);
117
- current = "";
118
- } else if (ch === "&") {
119
- const prev = command[i - 1];
120
- const next = command[i + 1];
121
- if (next === "&") {
122
- // `&&` is a command separator.
123
- segments.push(current);
124
- current = "";
125
- i++;
126
- } else if (prev === ">" || prev === "<" || next === ">") {
127
- // `>&`, `<&`, `&>`: redirection, not a separator.
128
- current += ch;
129
- } else {
130
- // Background job `&`.
131
- segments.push(current);
132
- current = "";
133
- }
134
- } else if (ch === "|") {
135
- if (command[i + 1] === "&") i++; // `|&` is still a pipe separator.
136
- segments.push(current);
137
- current = "";
138
- } else {
139
- current += ch;
140
- }
141
- }
142
- segments.push(current);
143
- return segments;
144
- }
145
-
146
- /** Tokenize a shell segment into words, removing quotes and tracking quoted leads. */
147
- function tokenize(segment: string): ShellWord[] {
148
- const words: ShellWord[] = [];
149
- let current = "";
150
- let leadingQuoted = false;
151
- let started = false;
152
- let quote: string | null = null;
153
- const push = () => {
154
- if (started) words.push({ text: current, leadingQuoted });
155
- current = "";
156
- leadingQuoted = false;
157
- started = false;
158
- };
159
- for (let i = 0; i < segment.length; i++) {
160
- const ch = segment[i];
161
- if (quote) {
162
- if (ch === "\\" && quote === '"' && i + 1 < segment.length) {
163
- const next = segment[i + 1];
164
- if (isShellEscapable(next)) current += segment[++i];
165
- else current += ch;
166
- } else if (ch === quote) {
167
- quote = null;
168
- } else {
169
- current += ch;
170
- }
171
- continue;
172
- }
173
- if (ch === "'" || ch === '"') {
174
- if (!started) {
175
- started = true;
176
- leadingQuoted = true;
177
- }
178
- quote = ch;
179
- } else if (ch === " " || ch === "\t") {
180
- push();
181
- } else if (ch === "\\" && i + 1 < segment.length) {
182
- started = true;
183
- const next = segment[i + 1];
184
- if (isShellEscapable(next)) current += segment[++i];
185
- else current += ch;
186
- } else {
187
- started = true;
188
- current += ch;
189
- }
190
- }
191
- push();
192
- return words;
193
- }
194
-
195
- /**
196
- * Expand `~` and environment variables so a path that only leaves the worktree
197
- * after shell expansion is still caught. `unresolved` marks a value that cannot
198
- * be expanded statically (unknown variable, `$`, backtick, `~user`); callers
199
- * treat it as escaping, because guessing could allow a bypass.
200
- */
201
- function expandShellPath(raw: string): { path?: string; unresolved: boolean } {
202
- let value = raw;
203
- if (value === "~") value = homedir();
204
- else if (value.startsWith("~/")) value = join(homedir(), value.slice(2));
205
- else if (value.startsWith("~")) return { unresolved: true };
206
-
207
- let unresolved = false;
208
- value = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, plain) => {
209
- const name = braced ?? plain;
210
- const env = process.env[name];
211
- if (env === undefined) {
212
- unresolved = true;
213
- return match;
214
- }
215
- return env;
216
- });
217
- if (value.includes("$") || value.includes("`")) unresolved = true;
218
- return { path: value, unresolved };
219
- }
220
-
221
- /** Whether a shell path argument leaves the worktree once expanded. */
222
- function pathEscapes(root: string, raw: string): boolean {
223
- const expanded = expandShellPath(raw);
224
- if (expanded.unresolved || !expanded.path) return true;
225
- return !isInside(root, resolve(root, expanded.path));
226
- }
227
-
228
- /**
229
- * Constructs whose text does not spell out the git command that runs. Exported
230
- * so the `tool_call` handler counts exactly the commands this check would have
231
- * refused, instead of guessing at a second list.
232
- */
233
- export function isUnverifiable(command: string): boolean {
234
- return /\$\(|`|\$\{!|\beval\b|\b(?:sh|bash|zsh|dash|ksh)\s+-c\b/.test(command);
235
- }
236
-
237
- // ---------------------------------------------------------------------------
238
- // Shell redirections (check 1b)
239
- // ---------------------------------------------------------------------------
240
-
241
- interface Redirect {
242
- target: string;
243
- /** True for output/read-write redirections; false for input-only. */
244
- write: boolean;
245
- }
246
-
247
- /** Read a shell word starting at `start`, skipping leading blanks, unquoting the result. */
248
- function readShellWord(text: string, start: number): { raw: string; end: number } {
249
- let i = start;
250
- while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
251
- let raw = "";
252
- let quote: string | null = null;
253
- while (i < text.length) {
254
- const ch = text[i];
255
- if (quote) {
256
- if (ch === "\\" && quote === '"' && i + 1 < text.length) {
257
- const next = text[i + 1];
258
- if (isShellEscapable(next)) {
259
- raw += next;
260
- i += 2;
261
- } else {
262
- raw += ch;
263
- i += 1;
264
- }
265
- continue;
266
- }
267
- if (ch === quote) {
268
- quote = null;
269
- i++;
270
- continue;
271
- }
272
- raw += ch;
273
- i++;
274
- continue;
275
- }
276
- if (ch === "'" || ch === '"') {
277
- quote = ch;
278
- i++;
279
- continue;
280
- }
281
- if (ch === "\\" && i + 1 < text.length) {
282
- const next = text[i + 1];
283
- if (isShellEscapable(next)) {
284
- raw += next;
285
- i += 2;
286
- } else {
287
- raw += ch;
288
- i += 1;
289
- }
290
- continue;
291
- }
292
- if (ch === " " || ch === "\t") break;
293
- raw += ch;
294
- i++;
295
- }
296
- return { raw, end: i };
297
- }
298
-
299
- /**
300
- * File redirection targets in one shell segment. File-descriptor duplications
301
- * (`2>&1`), heredocs (`<<EOF`), and here-strings (`<<<x`) are skipped because
302
- * their operand is not a path.
303
- */
304
- function redirectionTargets(segment: string): Redirect[] {
305
- const redirects: Redirect[] = [];
306
- let quote: string | null = null;
307
- for (let i = 0; i < segment.length; i++) {
308
- const ch = segment[i];
309
- if (quote) {
310
- if (ch === "\\" && quote === '"' && i + 1 < segment.length) {
311
- i++;
312
- continue;
313
- }
314
- if (ch === quote) quote = null;
315
- continue;
316
- }
317
- if (ch === "'" || ch === '"') {
318
- quote = ch;
319
- continue;
320
- }
321
- if (ch === "\\") {
322
- i++;
323
- continue;
324
- }
325
- if (ch !== ">" && ch !== "<") continue;
326
-
327
- const two = segment.slice(i, i + 2);
328
- const three = segment.slice(i, i + 3);
329
- if (three === "<<<") {
330
- i += 2;
331
- continue;
332
- }
333
- if (two === "<<") {
334
- // Heredoc delimiter: not a path.
335
- i += 1;
336
- continue;
337
- }
338
- if (two === ">&" || two === "<&") {
339
- // File-descriptor duplication (`2>&1`, `>&-`) is not a path, but bash
340
- // treats `>&file` as `&>file` (stdout+stderr to a file).
341
- const operand = readShellWord(segment, i + 2);
342
- if (operand.raw === "" || /^\d+$/.test(operand.raw) || operand.raw === "-") {
343
- i += 1;
344
- continue;
345
- }
346
- redirects.push({ target: operand.raw, write: ch === ">" });
347
- i = operand.end - 1;
348
- continue;
349
- }
350
- // `>`, `>>`, `>|`, `<`, `<>`: the next word is a path.
351
- const write = ch === ">" || two === "<>";
352
- if (two === ">>" || two === ">|") i += 1;
353
- const word = readShellWord(segment, i + 1);
354
- if (word.raw) redirects.push({ target: word.raw, write });
355
- i = word.end - 1;
356
- }
357
- return redirects;
358
- }
359
-
360
95
  /** Refuse an output/input redirection whose target leaves the worktree. */
361
96
  function redirectionEscapes(root: string, command: string, config: WorktreeConfig): string | undefined {
362
97
  const checkWrites = config.guard.blockFileEscapes;
@@ -383,13 +118,6 @@ function isNullDevice(raw: string): boolean {
383
118
  return raw.replace(/^(['"])(.*)\1$/, "$2") === "/dev/null";
384
119
  }
385
120
 
386
- /** Commands that wrap another command, so a following assignment is still a prefix. */
387
- const COMMAND_WRAPPERS = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "nohup", "nice", "time"]);
388
-
389
- function isAssignment(word: string): boolean {
390
- return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
391
- }
392
-
393
121
  /** A leading `GIT_DIR=`/`GIT_WORK_TREE=` assignment that points outside. */
394
122
  function envGitRedirect(root: string, words: ShellWord[]): string | undefined {
395
123
  for (let i = 0; i < words.length; i++) {
@@ -20,7 +20,12 @@ export interface Handoff {
20
20
  base: string | null;
21
21
  /** Commits on the branch that the base does not contain. */
22
22
  ahead: number;
23
- /** Whether the base already contains every commit on the branch. */
23
+ /**
24
+ * Commits the branch has made since the commit it was created at, when that was
25
+ * recorded. Absent for a worktree this package did not create.
26
+ */
27
+ commits?: number;
28
+ /** Whether the work landed: every commit is in the base, and there were commits. */
24
29
  merged: boolean;
25
30
  }
26
31
 
@@ -43,7 +48,10 @@ export function handoffLines(handoff: Handoff): string[] {
43
48
  ];
44
49
  }
45
50
  if (handoff.merged || handoff.ahead === 0) {
46
- const state = handoff.merged ? `is merged into ${handoff.base}` : `has no commits beyond ${handoff.base}`;
51
+ // "Merged" is a claim that work landed; a branch with no commits of its own never had
52
+ // any to land, and saying so is what stops an empty worktree reading as finished.
53
+ const landed = handoff.merged && handoff.commits !== 0;
54
+ const state = landed ? `is merged into ${handoff.base}` : `has no commits beyond ${handoff.base}`;
47
55
  return [`Handoff: ${handoff.branch} ${state} — nothing to merge.`];
48
56
  }
49
57
 
@@ -34,6 +34,7 @@ import {
34
34
  isWorktreeCancelled,
35
35
  refusalMessage,
36
36
  worktreeFacts,
37
+ enteredWorktree,
37
38
  worktreeLabel,
38
39
  } from "./lifecycle.ts";
39
40
  import { registerCommands } from "./commands.ts";
@@ -160,7 +161,7 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
160
161
  const name = flag.trim() === "true" ? undefined : flag.trim();
161
162
  try {
162
163
  const { state } = await enterWorktree(pi, ctx, { name });
163
- ctx.ui.notify(`Entered worktree ${worktreeLabel(state)}`, "info");
164
+ ctx.ui.notify(enteredWorktree(state), "info");
164
165
  } catch (error) {
165
166
  if (isWorktreeCancelled(error)) {
166
167
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "info");
@@ -12,6 +12,8 @@ export { exitWorktree, type ExitOptions } from "./exit.ts";
12
12
  export { pruneWorktrees } from "./prune.ts";
13
13
  export { findInactiveOverrides } from "./root-tools.ts";
14
14
  export {
15
+ enteredWorktree,
16
+ exitedWorktree,
15
17
  formatWorktreeStatus,
16
18
  refusalMessage,
17
19
  stateSummary,
@@ -6,8 +6,9 @@
6
6
  * is a disposable cache: a missing or corrupt file loads as empty, writes are
7
7
  * best-effort, and reads reconcile against `git worktree list`.
8
8
  *
9
- * `statusOf` is the single derived view used by status, prune, and (later)
10
- * switch/exit, so those do not each re-derive dirty/ahead/behind/merged state.
9
+ * `statusOf` is the single derived view the status listing reads, so the text and the
10
+ * screen do not each re-derive dirty/ahead/behind/merged state; exit reads the one fact it
11
+ * needs (`commitsAheadOf`) without it.
11
12
  */
12
13
 
13
14
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -15,14 +16,7 @@ import { dirname, join } from "node:path";
15
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
17
  import { realPathOfNearest } from "../../lib/path.ts";
17
18
  import type { WorktreeConfig } from "./config.ts";
18
- import {
19
- commitsAhead,
20
- commitsBehind,
21
- isBranchMerged,
22
- isStaleLock,
23
- type ManagedWorktree,
24
- statusEntries,
25
- } from "./git.ts";
19
+ import { commitsAhead, commitsBehind, isStaleLock, type ManagedWorktree, statusEntries } from "./git.ts";
26
20
 
27
21
  const REGISTRY_VERSION = 1;
28
22
 
@@ -53,8 +47,16 @@ export interface CheckoutStatus {
53
47
  state: "clean" | "dirty" | "missing";
54
48
  /** Number of changed files (porcelain entries). */
55
49
  changed: number;
50
+ /**
51
+ * Commits the branch has made since the commit it was created at.
52
+ *
53
+ * Absent when that commit was never recorded — a worktree the user pointed us at —
54
+ * because git keeps no fork point of its own to ask.
55
+ */
56
+ commits?: number;
56
57
  ahead: number;
57
58
  behind: number;
59
+ /** Whether the work landed: every commit is in the base, and there were commits. */
58
60
  merged: boolean;
59
61
  locked: boolean;
60
62
  staleLock: boolean;
@@ -218,10 +220,17 @@ export async function statusOf(
218
220
  const baseRef = options.baseRef;
219
221
  base.state = dirty.length > 0 ? "dirty" : "clean";
220
222
  base.changed = dirty.length;
223
+ // The commit the worktree was created at is the only fork point git can be asked about:
224
+ // once a branch is merged, it and a branch that never committed are both ancestors of
225
+ // the base, so `ahead` reports 0 for each and cannot tell them apart. This can.
226
+ const since = options.record?.base.commit;
227
+ if (since) base.commits = await commitsAhead(pi, path, since);
221
228
  if (baseRef) {
222
229
  base.ahead = await commitsAhead(pi, path, baseRef);
223
230
  base.behind = await commitsBehind(pi, path, baseRef);
224
- base.merged = base.ahead === 0 || (await isBranchMerged(pi, path, "HEAD", baseRef));
231
+ // `merged` claims the work landed, so it needs commits that could have landed: a
232
+ // branch with none is an empty checkout, not a finished one.
233
+ base.merged = base.ahead === 0 && base.commits !== 0;
225
234
  }
226
235
  return base;
227
236
  }
@@ -35,14 +35,24 @@ import { realPathOfNearest } from "../../lib/path.ts";
35
35
  export const ROOT_TOOL_NAMES = ["read", "write", "edit", "bash", "grep", "find", "ls"] as const;
36
36
  export type RootToolName = (typeof ROOT_TOOL_NAMES)[number];
37
37
 
38
- type AnyToolDef = ToolDefinition<any, any, any>;
38
+ /**
39
+ * A built-in tool definition, as the seven factories return it.
40
+ *
41
+ * Pi's own three defaults, spelled out rather than left implicit: `renderCall`'s arguments
42
+ * are contravariant in the parameter schema and its context in the detail type, so the
43
+ * seven concrete definitions do not widen to one `ToolDefinition<TSchema, unknown, ...>`
44
+ * — and neither does the test seam that replaces them with a stub. Erasing all three is
45
+ * the only alias that accepts both, which is what makes this line Pi's escape hatch
46
+ * rather than a missing type.
47
+ */
48
+ type BuiltinToolDef = ToolDefinition<any, any, any>;
39
49
 
40
50
  export interface FactoryOptions {
41
51
  read?: { autoResizeImages?: boolean };
42
52
  bash?: { commandPrefix?: string; shellPath?: string };
43
53
  }
44
54
 
45
- function createBuiltin(name: RootToolName, cwd: string, options: FactoryOptions): AnyToolDef {
55
+ function createBuiltin(name: RootToolName, cwd: string, options: FactoryOptions): BuiltinToolDef {
46
56
  switch (name) {
47
57
  case "read":
48
58
  return createReadToolDefinition(cwd, options.read);
@@ -62,7 +72,7 @@ function createBuiltin(name: RootToolName, cwd: string, options: FactoryOptions)
62
72
  }
63
73
 
64
74
  /** Host built-in factory. Injectable so tests avoid `mock.module`. */
65
- export type BuiltinFactory = (name: RootToolName, cwd: string, options: FactoryOptions) => AnyToolDef;
75
+ export type BuiltinFactory = (name: RootToolName, cwd: string, options: FactoryOptions) => BuiltinToolDef;
66
76
 
67
77
  export interface RootToolDeps {
68
78
  /** Override the host built-in factories. Tests inject a stub definition. */
@@ -21,8 +21,14 @@ export const ENV_BRANCH = "PI_WORKTREE_BRANCH";
21
21
  /** Main checkout a borrowed worktree forked from, so a child knows its `repoRoot`. */
22
22
  export const ENV_MAIN = "PI_WORKTREE_MAIN";
23
23
 
24
- /** Cross-extension notification channel. */
25
- export const WORKTREE_EVENT_CHANNEL = "worktree:changed";
24
+ /**
25
+ * The channel `publishWorktree` announces on, for another extension that follows the active
26
+ * root without polling; `docs/extensions/worktree.md` names it as the contract.
27
+ *
28
+ * Not exported: the channel is an event name on `pi.events`, so what crosses the boundary
29
+ * is the string a listener subscribes to, and the constant is this module's own.
30
+ */
31
+ const WORKTREE_EVENT_CHANNEL = "worktree:changed";
26
32
 
27
33
  let active: WorktreeState | null = null;
28
34
  /** Directory to use when not isolated (the session cwd at startup). */
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * The frame, navigation, y/N confirmation, and pane come from the shared
6
6
  * `DockListScreen`, so this module owns only the worktree vocabulary. Rows come
7
- * from the same `worktreeSnapshot` the text status renders, so the screen and the
8
- * `list_worktrees` tool cannot disagree about a worktree.
7
+ * from the same `worktreeSnapshot` the text status renders and their marks from the
8
+ * same `statusMarks`, so the screen and the `list_worktrees` tool cannot disagree
9
+ * about a worktree.
9
10
  *
10
11
  * Every verb but `p` closes the screen and hands the action back to the command:
11
12
  * entering and exiting change the session binding and `exitWorktree` asks through
@@ -16,6 +17,7 @@
16
17
  import { basename } from "node:path";
17
18
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
19
  import { messageOf } from "../../lib/errors.ts";
20
+ import { planModeRefusal } from "../../lib/plan-entry.ts";
19
21
  import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
20
22
  import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
21
23
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
@@ -75,11 +77,9 @@ function rowGlyph(status: CheckoutStatus | undefined): { char: string; role: Tui
75
77
  return { char: GLYPHS.branch, role: "info" };
76
78
  }
77
79
 
78
- /** The row's right-hand text: its marks, or `clean` when it has none. */
80
+ /** The row's right-hand text: its marks, worded once in `statusMarks`. */
79
81
  function rowMarks(row: WorktreeRow): string {
80
- const marks = row.status ? statusMarks(row.status, row.current) : ["current"];
81
- if (marks.length === 0) marks.push("clean");
82
- return marks.join(" ");
82
+ return (row.status ? statusMarks(row.status, row.current) : ["current"]).join(" ");
83
83
  }
84
84
 
85
85
  export interface WorktreeScreenOptions {
@@ -186,7 +186,13 @@ export class WorktreeListComponent extends DockListScreen<WorktreeRow> {
186
186
  const bits = [status.state === "dirty" ? `dirty (${status.changed} changed)` : "clean"];
187
187
  if (status.ahead > 0) bits.push(`${status.ahead} ahead`);
188
188
  if (status.behind > 0) bits.push(`${status.behind} behind`);
189
- if (status.merged) bits.push("merged");
189
+ // The row says it too, and for the same reason: a merged mark is a claim that work
190
+ // landed, which a worktree with no commits of its own cannot make.
191
+ if (status.commits === 0) bits.push("no commits");
192
+ else if (status.merged) {
193
+ if (status.commits !== undefined) bits.push(`${status.commits} commits`);
194
+ bits.push("merged");
195
+ }
190
196
  lines.push(`state ${bits.join(", ")}`);
191
197
  if (status.locked) {
192
198
  lines.push(`lock ${status.staleLock ? "stale (owner is gone)" : "held by a live session"}`);
@@ -200,6 +206,7 @@ export class WorktreeListComponent extends DockListScreen<WorktreeRow> {
200
206
  branch: row.branch,
201
207
  base: this.view.baseBranch ?? null,
202
208
  ahead: status.ahead,
209
+ commits: status.commits,
203
210
  merged: status.merged,
204
211
  }),
205
212
  );
@@ -211,7 +218,7 @@ export class WorktreeListComponent extends DockListScreen<WorktreeRow> {
211
218
  protected handleVerb(key: string, row: WorktreeRow | undefined): void {
212
219
  if (key !== "e" && key !== "x" && key !== "p") return;
213
220
  if (this.deps.readOnly) {
214
- this.notify("Plan mode is read-only. Approve the plan before changing worktrees.");
221
+ this.notify(planModeRefusal("changing worktrees"));
215
222
  return;
216
223
  }
217
224
  if (key === "e") {