@gavin-hu/my-pi-agent 0.21.0 → 0.22.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 (254) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +40 -26
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -24
  9. package/docs/extensions/board.md +361 -147
  10. package/docs/extensions/bytefly.md +152 -111
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +30 -31
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +166 -126
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +34 -18
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +72 -33
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +56 -15
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +5 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +4 -40
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +13 -11
  38. package/extensions/board/result.ts +30 -16
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +42 -10
  41. package/extensions/board/snapshot.ts +2 -1
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +131 -44
  44. package/extensions/board/types.ts +36 -32
  45. package/extensions/board/view/card.ts +229 -0
  46. package/extensions/board/view/client.ts +33 -15
  47. package/extensions/board/view/dialog.ts +169 -6
  48. package/extensions/board/view/empty.ts +47 -0
  49. package/extensions/board/view/keys.ts +13 -2
  50. package/extensions/board/view/live.ts +89 -21
  51. package/extensions/board/view/names.ts +13 -4
  52. package/extensions/board/view/page.ts +23 -9
  53. package/extensions/board/view/render.ts +208 -271
  54. package/extensions/board/view/styles.ts +123 -31
  55. package/extensions/board/view/team.ts +297 -76
  56. package/extensions/bytefly/block/fallback.ts +1 -1
  57. package/extensions/bytefly/doctor/runtime.ts +5 -2
  58. package/extensions/bytefly/footer/collect/label.ts +60 -0
  59. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  60. package/extensions/bytefly/footer/config.ts +4 -2
  61. package/extensions/bytefly/footer/register.ts +33 -4
  62. package/extensions/bytefly/footer/render/lines.ts +7 -4
  63. package/extensions/bytefly/footer/render/segments.ts +37 -10
  64. package/extensions/bytefly/footer/render/status.ts +0 -11
  65. package/extensions/bytefly/footer/types.ts +7 -0
  66. package/extensions/bytefly/header/config.ts +1 -1
  67. package/extensions/bytefly/index.ts +32 -6
  68. package/extensions/bytefly/mode/editor.ts +115 -0
  69. package/extensions/bytefly/panel/commands.ts +210 -120
  70. package/extensions/bytefly/panel/format.ts +4 -4
  71. package/extensions/bytefly/panel/settings.ts +73 -6
  72. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  73. package/extensions/bytefly/update/cache.ts +1 -1
  74. package/extensions/bytefly/update/register.ts +4 -4
  75. package/extensions/bytefly/working/config.ts +5 -7
  76. package/extensions/bytefly/working/format.ts +10 -13
  77. package/extensions/bytefly/working/register.ts +22 -27
  78. package/extensions/check/commands.ts +3 -2
  79. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  80. package/extensions/context/commands.ts +2 -2
  81. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  82. package/extensions/file-browse/index.ts +67 -36
  83. package/extensions/file-read/render.ts +4 -4
  84. package/extensions/goal/plan-route.ts +2 -2
  85. package/extensions/job/child.ts +527 -0
  86. package/extensions/job/clock.ts +142 -0
  87. package/extensions/job/commands.ts +4 -2
  88. package/extensions/job/config.ts +8 -1
  89. package/extensions/job/format.ts +22 -1
  90. package/extensions/job/log-writer.ts +13 -2
  91. package/extensions/job/registry.ts +63 -7
  92. package/extensions/job/render.ts +34 -0
  93. package/extensions/job/runtime.ts +296 -655
  94. package/extensions/job/session.ts +132 -19
  95. package/extensions/job/store.ts +7 -7
  96. package/extensions/job/tools.ts +3 -3
  97. package/extensions/job/types.ts +47 -0
  98. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
  99. package/extensions/job/waiters.ts +44 -1
  100. package/extensions/memory/commands.ts +4 -2
  101. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  102. package/extensions/plan/commands.ts +12 -9
  103. package/extensions/plan/format.ts +8 -2
  104. package/extensions/plan/git-bash.ts +115 -16
  105. package/extensions/plan/index.ts +53 -52
  106. package/extensions/plan/plan-index.ts +4 -2
  107. package/extensions/plan/plans.ts +5 -54
  108. package/extensions/plan/policy.ts +8 -10
  109. package/extensions/plan/review-lazy.ts +85 -0
  110. package/extensions/plan/review.ts +2 -2
  111. package/extensions/plan/runtime.ts +49 -39
  112. package/extensions/plan/sections.ts +1 -1
  113. package/extensions/plan/steps.ts +3 -3
  114. package/extensions/plan/todos.ts +1 -1
  115. package/extensions/plan/tools/cancel.ts +27 -62
  116. package/extensions/plan/tools/enter.ts +55 -81
  117. package/extensions/plan/tools/exit.ts +221 -238
  118. package/extensions/plan/tools/refusal.ts +18 -0
  119. package/extensions/plan/tools/steps.ts +75 -92
  120. package/extensions/plan/tools/write.ts +77 -120
  121. package/extensions/plan/tools.ts +188 -30
  122. package/extensions/plan/tui.ts +5 -5
  123. package/extensions/plan/types.ts +48 -5
  124. package/extensions/plan/web-client.ts +3 -0
  125. package/extensions/plan/web-lazy.ts +53 -0
  126. package/extensions/plan/web-markdown.ts +9 -67
  127. package/extensions/plan/web-payload.ts +3 -3
  128. package/extensions/plan/web-router.ts +18 -9
  129. package/extensions/plan/web.ts +1 -12
  130. package/extensions/question/tools.ts +6 -2
  131. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  132. package/extensions/rewind/commands/commands.ts +6 -3
  133. package/extensions/rewind/state/runtime.ts +1 -1
  134. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  135. package/extensions/rewind/view/review-tui.ts +4 -4
  136. package/extensions/serve/config.ts +10 -8
  137. package/extensions/serve/index.ts +17 -3
  138. package/extensions/serve/registry.ts +26 -17
  139. package/extensions/session-bus/commands.ts +11 -6
  140. package/extensions/session-bus/join-request.ts +2 -1
  141. package/extensions/session-bus/mail-watch.ts +26 -17
  142. package/extensions/session-bus/protocol.ts +15 -3
  143. package/extensions/session-bus/resolve.ts +5 -7
  144. package/extensions/session-bus/runtime.ts +40 -12
  145. package/extensions/session-bus/store.ts +101 -19
  146. package/extensions/session-bus/types.ts +19 -0
  147. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  148. package/extensions/subagent/agents.ts +1 -1
  149. package/extensions/subagent/commands.ts +4 -2
  150. package/extensions/subagent/format.ts +15 -15
  151. package/extensions/subagent/index.ts +1 -1
  152. package/extensions/subagent/render.ts +3 -3
  153. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  154. package/extensions/team/commands.ts +24 -7
  155. package/extensions/team/dispatch.ts +18 -4
  156. package/extensions/team/format.ts +17 -2
  157. package/extensions/team/index.ts +9 -8
  158. package/extensions/team/schema.ts +6 -5
  159. package/extensions/team/tools.ts +107 -45
  160. package/extensions/team/types.ts +3 -2
  161. package/extensions/todo/commands.ts +4 -2
  162. package/extensions/todo/format.ts +11 -0
  163. package/extensions/todo/render.ts +57 -0
  164. package/extensions/todo/runtime.ts +1 -1
  165. package/extensions/todo/tools.ts +1 -1
  166. package/extensions/todo/view/todo-screen.ts +122 -0
  167. package/extensions/todo/widget.ts +47 -0
  168. package/extensions/watch/commands.ts +4 -2
  169. package/extensions/watch/format.ts +36 -7
  170. package/extensions/watch/render.ts +57 -0
  171. package/extensions/watch/runtime.ts +27 -22
  172. package/extensions/watch/tools.ts +4 -4
  173. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  174. package/extensions/web-access/fetch/tool.ts +12 -4
  175. package/extensions/wechat/commands/commands.ts +18 -14
  176. package/extensions/wechat/index.ts +72 -37
  177. package/extensions/wechat/state/credentials.ts +1 -1
  178. package/extensions/wechat/state/state.ts +1 -1
  179. package/extensions/wechat/text/status.ts +112 -0
  180. package/extensions/wechat/types.ts +2 -2
  181. package/extensions/wechat/view/status-screen.ts +4 -103
  182. package/extensions/worktree/commands.ts +11 -9
  183. package/extensions/worktree/enter.ts +2 -2
  184. package/extensions/worktree/exit.ts +2 -2
  185. package/extensions/worktree/index.ts +5 -5
  186. package/extensions/worktree/screen.ts +3 -3
  187. package/extensions/worktree/status.ts +2 -2
  188. package/extensions/worktree/tools.ts +113 -116
  189. package/lib/README.md +27 -13
  190. package/lib/board/actions.ts +111 -0
  191. package/lib/board/artifact.ts +149 -0
  192. package/lib/board/card.ts +163 -52
  193. package/lib/board/config.ts +58 -0
  194. package/lib/board/contract.ts +113 -0
  195. package/lib/board/join.ts +38 -0
  196. package/lib/board/lanes.ts +182 -0
  197. package/lib/board/lead.ts +130 -0
  198. package/lib/board/limits.ts +78 -0
  199. package/lib/board/links.ts +31 -10
  200. package/lib/board/page-pointer.ts +1 -1
  201. package/lib/board/read.ts +83 -0
  202. package/lib/board/reason.ts +102 -0
  203. package/lib/board/round-text.ts +68 -6
  204. package/lib/board/round.ts +31 -15
  205. package/lib/board/rules.ts +151 -307
  206. package/lib/board/seat.ts +35 -0
  207. package/lib/board/session.ts +136 -54
  208. package/lib/board/skipped.ts +22 -0
  209. package/lib/board/steps.ts +487 -0
  210. package/lib/board/store.ts +87 -30
  211. package/lib/board/tone.ts +92 -0
  212. package/lib/board/view.ts +70 -58
  213. package/{extensions/watch → lib}/child.ts +40 -21
  214. package/lib/env.ts +65 -7
  215. package/lib/format.ts +13 -0
  216. package/lib/http/index.ts +14 -12
  217. package/lib/http/port.ts +15 -10
  218. package/lib/lazy.ts +45 -0
  219. package/lib/plan-entry.ts +39 -10
  220. package/lib/plans/paths.ts +82 -0
  221. package/lib/policy.ts +9 -6
  222. package/lib/report.ts +3 -2
  223. package/lib/requirements.ts +8 -8
  224. package/lib/session-label.ts +111 -5
  225. package/lib/session-roster.ts +43 -1
  226. package/lib/session-tool.ts +5 -4
  227. package/lib/stray-fields.ts +7 -2
  228. package/lib/tool-names.ts +5 -10
  229. package/lib/tui/README.md +23 -8
  230. package/lib/tui/chrome.ts +16 -15
  231. package/lib/tui/cursor.ts +18 -3
  232. package/lib/tui/glyphs.ts +9 -7
  233. package/lib/tui/tokens.ts +33 -2
  234. package/lib/tui/tool.ts +3 -3
  235. package/lib/web/README.md +21 -0
  236. package/lib/web/components.ts +5 -1
  237. package/lib/web/index.ts +1 -0
  238. package/lib/web/markdown.ts +83 -0
  239. package/lib/web/markup.ts +2 -1
  240. package/lib/web/runtime.ts +9 -10
  241. package/package.json +6 -1
  242. package/skills/init/SKILL.md +5 -5
  243. package/skills/next/SKILL.md +3 -5
  244. package/extensions/board/seat.ts +0 -28
  245. package/extensions/todo/tui.ts +0 -212
  246. package/lib/tui/index.ts +0 -34
  247. package/skills/project-docs/SKILL.md +0 -133
  248. package/skills/project-docs/references/audit.md +0 -131
  249. package/skills/project-docs/references/changelog.md +0 -105
  250. package/skills/project-docs/references/discovery.md +0 -127
  251. package/skills/project-docs/references/readme.md +0 -80
  252. package/skills/project-docs/references/release.md +0 -84
  253. package/skills/project-docs/references/roadmap.md +0 -93
  254. package/skills/project-docs/scripts/state.ts +0 -316
package/lib/env.ts CHANGED
@@ -12,24 +12,82 @@ export const ENV_ROOT = "PI_WORKTREE_ROOT";
12
12
 
13
13
  /**
14
14
  * Env var listing extension names to skip. Entries are separated by commas
15
- * and/or whitespace. Unset or empty means every extension loads.
15
+ * and/or whitespace. Unset or empty means every extension loads except the
16
+ * opt-in ones.
16
17
  */
17
18
  export const ENV_DISABLED_EXTENSIONS = "PI_DISABLED_EXTENSIONS";
18
19
 
20
+ /**
21
+ * Env var listing the opt-in extensions to load. Entries are separated by
22
+ * commas and/or whitespace. Only `OPT_IN_EXTENSIONS` reads it; it never
23
+ * switches off an extension that loads by default.
24
+ */
25
+ export const ENV_ENABLED_EXTENSIONS = "PI_ENABLED_EXTENSIONS";
26
+
19
27
  /** Env var that turns off Pi's automatic network activity, including our own. */
20
28
  export const ENV_OFFLINE = "PI_OFFLINE";
21
29
 
22
- /** False when `name` appears in `PI_DISABLED_EXTENSIONS`, true otherwise. */
23
- export function isExtensionEnabled(name: string): boolean {
24
- const raw = process.env[ENV_DISABLED_EXTENSIONS];
25
- if (!raw) return true;
26
- const disabled = new Set(
30
+ /**
31
+ * Extensions that stay unloaded unless `PI_ENABLED_EXTENSIONS` names them.
32
+ *
33
+ * `board` and `team` are one workflow — a work list, and the lead's half of it
34
+ * — so a session opts into both or neither. They stay independent at runtime:
35
+ * naming one never loads the other.
36
+ *
37
+ * They are declared in the package manifest like every other extension, so Pi
38
+ * still discovers them and their docs stay in the pack; the surface is simply
39
+ * not registered until the session asks for it. `new Set` rather than an array
40
+ * literal because `test/boundaries.test.ts` rejects a module-level array in
41
+ * `lib/`, and a fixed keyword table is the one container that rule allows.
42
+ */
43
+ export const OPT_IN_EXTENSIONS: ReadonlySet<string> = new Set(["board", "team"]);
44
+
45
+ /** What the environment says about one extension this session. */
46
+ export type ExtensionEnablement = "on" | "disabled" | "opt-in";
47
+
48
+ /** The names in an extension-list variable, lowercased; empty when it is unset. */
49
+ function listedExtensions(variable: string): Set<string> {
50
+ const raw = process.env[variable];
51
+ if (!raw) return new Set();
52
+ return new Set(
27
53
  raw
28
54
  .split(/[\s,]+/)
29
55
  .map((entry) => entry.trim().toLowerCase())
30
56
  .filter(Boolean),
31
57
  );
32
- return !disabled.has(name.toLowerCase());
58
+ }
59
+
60
+ /**
61
+ * How this session treats `name`: `disabled` when `PI_DISABLED_EXTENSIONS`
62
+ * names it, `opt-in` when only `PI_ENABLED_EXTENSIONS` can switch it on and has
63
+ * not, and `on` otherwise. An explicit disable wins over an opt-in name.
64
+ */
65
+ export function extensionEnablement(name: string): ExtensionEnablement {
66
+ const key = name.toLowerCase();
67
+ if (listedExtensions(ENV_DISABLED_EXTENSIONS).has(key)) return "disabled";
68
+ if (OPT_IN_EXTENSIONS.has(key) && !listedExtensions(ENV_ENABLED_EXTENSIONS).has(key)) return "opt-in";
69
+ return "on";
70
+ }
71
+
72
+ /** Whether `name` loads this session, i.e. {@link extensionEnablement} says `on`. */
73
+ export function isExtensionEnabled(name: string): boolean {
74
+ return extensionEnablement(name) === "on";
75
+ }
76
+
77
+ /**
78
+ * What a report says about `name`: why this session is not loading it, or
79
+ * undefined when it is. One call answers both, so the state and the sentence
80
+ * cannot disagree, and the switch it names is the one that would change it.
81
+ */
82
+ export function extensionDisabledReason(name: string): string | undefined {
83
+ switch (extensionEnablement(name)) {
84
+ case "on":
85
+ return undefined;
86
+ case "opt-in":
87
+ return `an opt-in extension; load it with \`${ENV_ENABLED_EXTENSIONS}=${name.toLowerCase()}\``;
88
+ case "disabled":
89
+ return `disabled through \`${ENV_DISABLED_EXTENSIONS}\``;
90
+ }
33
91
  }
34
92
 
35
93
  /**
package/lib/format.ts CHANGED
@@ -38,6 +38,19 @@ export function formatCount(count: number): string {
38
38
  return sign + out;
39
39
  }
40
40
 
41
+ /**
42
+ * A list in a sentence: `a, b, or c`, or the same with `and`.
43
+ *
44
+ * One body, because a refusal that names the lanes a card may move to, one that names the
45
+ * fields a contract needs, and one that names the lanes a tool accepts are the same
46
+ * sentence, and a second copy drifts on the comma.
47
+ */
48
+ export function listOf(entries: readonly string[], conjunction: "or" | "and" = "or"): string {
49
+ if (entries.length === 0) return "nothing";
50
+ if (entries.length === 1) return entries[0] ?? "nothing";
51
+ return `${entries.slice(0, -1).join(", ")}, ${conjunction} ${entries[entries.length - 1]}`;
52
+ }
53
+
41
54
  /**
42
55
  * Compact duration: `450ms`, `3.2s`, `1m05s`, `2h03m`. Non-finite or
43
56
  * negative input reads `0ms`, so a missing timestamp never prints `NaN`.
package/lib/http/index.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  /**
2
- * The loopback HTTP layer shared by `file-browse`, `plan`, and `board`: the
3
- * response shape, the header policy, the request-envelope helpers and the
4
- * guards built on them, the one server entry, the socket seam behind it, and the
5
- * mount contract a page uses to claim a prefix on the session's one port.
2
+ * The served-page kit: the loopback HTTP layer `file-browse`, `plan`, and `board`
3
+ * answer on — the response shape, the header policy, the request-envelope
4
+ * helpers and the guards built on them, the one server entry, the socket seam
5
+ * behind it, and the port band it draws from.
6
6
  *
7
- * Import from here rather than a leaf module, so the layer reads as one kit. The
8
- * `serve` extension imports {@link serveHttp} and never `./server.ts`; every other
9
- * extension imports {@link requestMount} and never binds a socket at all — a
10
- * hand-built `node:http` server beside this one is how the close path and the idle
11
- * grace start to differ again. The same rule covers the guards: `plan` and `board`
12
- * both serve a page that can write, so the `Host`, origin, and JSON rules are
13
- * {@link envelopeRefusal} and {@link requireJsonBody} rather than two copies, and
14
- * the credential a page carries is {@link mintToken} / {@link tokenMatches}.
7
+ * Import from here rather than a leaf module, so the layer reads as one kit. This
8
+ * barrel is deliberately *not* on a registration path: it is 978 lines that only
9
+ * a page that is actually served needs, so an extension reaches it from the
10
+ * handler that mounts one, never from its factory. The one exception is the
11
+ * mount contract itself — {@link requestMount} and {@link HTTP_MOUNT_CHANNEL},
12
+ * which live in `./mount.ts` — because asking for a prefix is what a command does
13
+ * before any page exists. `board` and `plan` import that leaf directly; `serve`
14
+ * imports it for the channel and loads this barrel on the first bind; a new
15
+ * `lib/http/` leaf imported from a factory puts the whole kit back on every start,
16
+ * which `test/dependencies.test.ts` fails.
15
17
  *
16
18
  * Value-only, like the rest of `lib/`: the only state is what a returned closure
17
19
  * carries (`createCloser`, `serveHttp`).
package/lib/http/port.ts CHANGED
@@ -1,25 +1,30 @@
1
1
  /**
2
- * The per-session port band the loopback pages share.
2
+ * The port band the loopback pages share, and the number a project keeps in it.
3
3
  *
4
- * `serve` draws the session's one port from it, so every page that session serves
4
+ * `serve` takes a session's one port from here, so every page that session serves
5
5
  * clusters where a person's bookmarks already point. The band is one product decision,
6
6
  * stated here once: a number repeated in each extension's config is several chances to
7
7
  * move one of them, and nothing would notice until a bookmark landed outside the range.
8
8
  *
9
+ * A project's number is its repository key hashed into the band rather than a fresh
10
+ * draw, so a session that starts again lands on the port its bookmark names; two
11
+ * repositories take two numbers without a setting. `serve.port` overrides it.
12
+ *
9
13
  * A pick is a guess, not a reservation. `serveHttp` binds it and falls back to whatever
10
- * free port the OS gives, so a port another process holds costs a retry rather than a
11
- * start.
14
+ * free port the OS gives, so a port another process holds — or another project's hash
15
+ * landing in the same slot — costs a retry rather than a start.
12
16
  */
13
17
 
14
- import { randomInt } from "node:crypto";
18
+ import { createHash } from "node:crypto";
15
19
 
16
- /** Base of the per-session port range. */
20
+ /** Base of the per-project port range. */
17
21
  export const PORT_BASE = 4780;
18
22
 
19
- /** Number of ports in the per-session range. */
23
+ /** Number of ports in the per-project range. */
20
24
  export const PORT_RANGE = 1000;
21
25
 
22
- /** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
23
- export function randomPort(): number {
24
- return PORT_BASE + randomInt(PORT_RANGE);
26
+ /** The port a project prefers: its repository key hashed into `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
27
+ export function projectPort(repoKey: string): number {
28
+ const digest = createHash("sha256").update(repoKey).digest();
29
+ return PORT_BASE + (digest.readUInt32BE(0) % PORT_RANGE);
25
30
  }
package/lib/lazy.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One async load, run at most once.
3
+ *
4
+ * A surface a command needs but registration does not — a browser page, a
5
+ * review server, a screen — is imported through this: `get()` runs the loader
6
+ * and remembers the value, and `peek()` answers the value already loaded, or
7
+ * `undefined`, so a shutdown path never starts a load the session never used.
8
+ *
9
+ * A rejected load is not remembered. A transient failure can be retried, and a
10
+ * caller that sees the error can ask again; the alternative would make one bad
11
+ * import permanent for the process.
12
+ *
13
+ * State lives in this closure, not at module scope, because `lib/` is loaded
14
+ * with isolated module caches and is value-only.
15
+ */
16
+
17
+ /** A value loaded on first use, with a way to ask whether it is already here. */
18
+ export interface Lazy<T> {
19
+ /** Load the value, or return the one already loaded; concurrent calls share one load. */
20
+ get(): Promise<T>;
21
+ /** The loaded value, or `undefined` before a load and after a failed one. */
22
+ peek(): T | undefined;
23
+ }
24
+
25
+ export function lazy<T>(load: () => Promise<T>): Lazy<T> {
26
+ let loaded = false;
27
+ let value: T;
28
+ let pending: Promise<T> | undefined;
29
+ const get = (): Promise<T> => {
30
+ if (loaded) return Promise.resolve(value);
31
+ pending ??= load().then(
32
+ (result) => {
33
+ loaded = true;
34
+ value = result;
35
+ return result;
36
+ },
37
+ (error: unknown) => {
38
+ pending = undefined;
39
+ throw error;
40
+ },
41
+ );
42
+ return pending;
43
+ };
44
+ return { get, peek: () => (loaded ? value : undefined) };
45
+ }
package/lib/plan-entry.ts CHANGED
@@ -18,20 +18,24 @@
18
18
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
19
  import { type BusAnswer, requestOverBus } from "./bus-request.ts";
20
20
  import { isPlanModeEnabled } from "./plan-state.ts";
21
- import { PLAN_ENTER_TOOL } from "./tool-names.ts";
21
+ import { PLAN_TOOL } from "./tool-names.ts";
22
22
 
23
23
  /** EventBus channel on which a caller asks `plan` to enable plan mode. */
24
24
  export const PLAN_ENTER_REQUEST = "my-pi-agent/plan-mode/enter";
25
25
 
26
+ /** EventBus channel on which a caller asks `plan` to flip the mode. */
27
+ export const PLAN_TOGGLE_REQUEST = "my-pi-agent/plan-mode/toggle";
28
+
26
29
  /** One clause describing the read-only guarantee, reused across prompts. */
27
30
  export const READ_ONLY_SUMMARY =
28
31
  "write and edit are disabled, bash is limited to read-only git commands, and other mutating tools are blocked; " +
29
- "subagent delegation is forced read-only, and the only file write is write_plan (a plan file in the agent " +
30
- "directory)";
32
+ "subagent delegation is forced read-only, and the only file write is the plan file, saved by the `plan` tool's " +
33
+ "`write` action in the agent directory";
31
34
 
32
35
  /**
33
- * The consent question, as the user sees it. Shown by `enter_plan_mode` and by
34
- * any caller that offers plan mode itself, so both describe the same guarantee.
36
+ * The consent question, as the user sees it. Shown by the `plan` tool's `enter`
37
+ * action and by any caller that offers plan mode itself, so both describe the same
38
+ * guarantee.
35
39
  */
36
40
  export const PLAN_CONSENT =
37
41
  `Plan mode is read-only exploration: ${READ_ONLY_SUMMARY}. ` +
@@ -53,12 +57,19 @@ export function planModeRefusal(action: string): string {
53
57
  return `Plan mode is read-only. Approve the plan before ${action}.`;
54
58
  }
55
59
 
56
- /** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
57
- export interface PlanEnterRequest {
60
+ /** What a caller sends on {@link PLAN_ENTER_REQUEST} or {@link PLAN_TOGGLE_REQUEST}. */
61
+ export interface PlanModeRequest {
58
62
  /** The caller's context; `plan` uses it for the footer status. */
59
63
  ctx: ExtensionContext;
60
64
  /** Claimed once, with whether plan mode is on; the caller settles on that claim. */
61
- respond(entered: BusAnswer<boolean>): void;
65
+ respond(enabled: BusAnswer<boolean>): void;
66
+ }
67
+
68
+ /**
69
+ * Whether the `plan` extension registered its tool in this session.
70
+ */
71
+ export function isPlanExtensionLoaded(pi: Pick<ExtensionAPI, "getAllTools">): boolean {
72
+ return pi.getAllTools().some((tool) => tool.name === PLAN_TOOL);
62
73
  }
63
74
 
64
75
  /**
@@ -74,10 +85,28 @@ export function requestPlanMode(
74
85
  ctx: ExtensionContext,
75
86
  ): Promise<boolean> {
76
87
  if (isPlanModeEnabled(ctx.sessionManager.getBranch())) return Promise.resolve(true);
77
- if (!pi.getAllTools().some((tool) => tool.name === PLAN_ENTER_TOOL)) return Promise.resolve(false);
78
- return requestOverBus<boolean, PlanEnterRequest>(pi, {
88
+ if (!isPlanExtensionLoaded(pi)) return Promise.resolve(false);
89
+ return requestOverBus<boolean, PlanModeRequest>(pi, {
79
90
  channel: PLAN_ENTER_REQUEST,
80
91
  payload: (respond) => ({ ctx, respond }),
81
92
  unclaimed: () => false,
82
93
  });
83
94
  }
95
+
96
+ /**
97
+ * Ask `plan` to flip the mode, and report the state it left behind.
98
+ *
99
+ * A toggle has no branch precondition: the owner reads the flag it persisted and
100
+ * resolves the opposite. `undefined` means nothing is listening (the `plan`
101
+ * extension is not loaded), so the caller can leave its own key alone.
102
+ */
103
+ export function requestPlanToggle(
104
+ pi: Pick<ExtensionAPI, "events">,
105
+ ctx: ExtensionContext,
106
+ ): Promise<boolean | undefined> {
107
+ return requestOverBus<boolean | undefined, PlanModeRequest>(pi, {
108
+ channel: PLAN_TOGGLE_REQUEST,
109
+ payload: (respond) => ({ ctx, respond }),
110
+ unclaimed: () => undefined,
111
+ });
112
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Where a plan lives, and which paths may be read or written as one.
3
+ *
4
+ * A plan file is the artifact a card is executed against, so two extensions have to
5
+ * agree about it: `plan` writes and reads the store, and `board` records the file a
6
+ * card promises. The store is `<agent-dir>/plans/<repo-key>` for a repository and
7
+ * `<agent-dir>/plans` outside one — the key is the same one `repo-key.ts` hands
8
+ * every other per-repository store, which is why the keyed branch lives here rather
9
+ * than in either caller.
10
+ *
11
+ * The rules are one body for the same reason: a copy of `isContainedPlan` that
12
+ * dropped the symlink half would look identical at a glance and would be the one
13
+ * that let a link inside the store point outside it. `plan` passes a path here
14
+ * before it writes; `board` passes the path it is about to record, and asks whether
15
+ * it is a plan of this repository.
16
+ *
17
+ * Value-only: no module state, and the only I/O is the `lstat` a symlink check needs.
18
+ */
19
+
20
+ import { lstatSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
23
+ import { isInside, isInsideReal } from "../path.ts";
24
+ import type { RepoKey } from "../repo-key.ts";
25
+
26
+ /** The agent-directory entry plans are filed under. */
27
+ export const PLANS_DIR_NAME = "plans";
28
+
29
+ /** The plan store: `<agent-dir>/plans`, or `<agent-dir>/plans/<key>` inside a repository. */
30
+ export function plansDir(agentDir: string, key?: string): string {
31
+ return key === undefined ? join(agentDir, PLANS_DIR_NAME) : join(agentDir, PLANS_DIR_NAME, key);
32
+ }
33
+
34
+ /**
35
+ * The store for a resolved repository: keyed when git answered, unkeyed outside one.
36
+ *
37
+ * The `fromGit` branch lives here and nowhere else — that is the line a copy gets wrong,
38
+ * and both the plan tool and the board reach it through this one function.
39
+ */
40
+ export function plansDirFor(agentDir: string, repo: Pick<RepoKey, "key" | "fromGit">): string {
41
+ return plansDir(agentDir, repo.fromGit ? repo.key : undefined);
42
+ }
43
+
44
+ /** A plan file name: markdown and not a dotfile, so the self-ignore file is never a target. */
45
+ export function isPlanFileName(path: string): boolean {
46
+ const name = basename(path);
47
+ return name.toLowerCase().endsWith(".md") && !name.startsWith(".");
48
+ }
49
+
50
+ /**
51
+ * Whether `path` may be read, written, or deleted as a plan. Beyond lexical
52
+ * containment (shared with the other path guards through `lib/path.ts`), this
53
+ * rejects dotfiles (including the directory's own `.gitignore`) and resolves
54
+ * symlinks, so a link placed inside the plans directory cannot redirect a read
55
+ * or write outside it.
56
+ */
57
+ export function isContainedPlan(dir: string, path: string): boolean {
58
+ if (!isInside(dir, path) || !isPlanFileName(path)) return false;
59
+ try {
60
+ // A symlink itself must never be the target; a missing file is a new plan.
61
+ if (lstatSync(path).isSymbolicLink()) return false;
62
+ } catch {
63
+ // Missing (ENOENT) is expected; any other error falls through to the
64
+ // real-path check, which rejects the path if it cannot be resolved.
65
+ }
66
+ return isInsideReal(dir, dirname(path));
67
+ }
68
+
69
+ /**
70
+ * Resolve a `plan_path` argument against the store.
71
+ *
72
+ * A plan lives outside the project, so a path relative to the working directory
73
+ * can no longer name one. Absolute paths and the `~/…` form the tools display are
74
+ * accepted, and a bare name resolves inside the store — which is what a reader of
75
+ * a tool result has in hand.
76
+ */
77
+ export function resolvePlanArg(dir: string, planPath: string): string {
78
+ const text = planPath.trim();
79
+ if (text === "~") return homedir();
80
+ if (text.startsWith("~/") || text.startsWith(`~${sep}`)) return resolve(homedir(), text.slice(2));
81
+ return isAbsolute(text) ? resolve(text) : resolve(dir, text);
82
+ }
package/lib/policy.ts CHANGED
@@ -10,24 +10,27 @@
10
10
  * `write`/`edit` being the only mutators.
11
11
  *
12
12
  * No host dependency beyond the annotation type: the caller supplies the
13
- * annotation lookup, and the one reader name comes from `tool-names.ts`, so this
14
- * module stays value-only.
13
+ * annotation lookup, the one reader name comes from `tool-names.ts`, and which board
14
+ * actions read comes from `board/actions.ts`, so this module stays value-only.
15
15
  */
16
16
 
17
17
  import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
18
- import { BOARD_READ_ACTION, READ_DOC_TOOL } from "./tool-names.ts";
18
+ import { isBoardReadAction } from "./board/actions.ts";
19
+ import { READ_DOC_TOOL } from "./tool-names.ts";
19
20
 
20
21
  /**
21
22
  * Whether a call to the `board` tool only reads it.
22
23
  *
23
- * The board is one tool over ten actions, so its `readOnlyHint` says "may write" and
24
+ * The board is one tool over many actions, so its `readOnlyHint` says "may write" and
24
25
  * cannot answer this. The action can, and it travels in the call's input — and in the
25
26
  * result's `details`, which is all a `tool_execution_end` event carries. Plan mode
26
- * asks before the call and the todo nudge after it, so the answer lives here once.
27
+ * asks before the call and the todo nudge after it, so the answer lives here once —
28
+ * and the answer is the board's own table, because an action list kept here is a list
29
+ * that drifts.
27
30
  */
28
31
  export function isBoardRead(call: unknown): boolean {
29
32
  if (typeof call !== "object" || call === null) return false;
30
- return (call as { action?: unknown }).action === BOARD_READ_ACTION;
33
+ return isBoardReadAction((call as { action?: unknown }).action);
31
34
  }
32
35
 
33
36
  /**
package/lib/report.ts CHANGED
@@ -147,8 +147,9 @@ export interface PendingChannelOptions {
147
147
  * The order inside `session_start` is the contract: the reporter forgets the
148
148
  * previous session's busy window, the runtime loads the records, and only then
149
149
  * is the report trigger armed — a reload or a replaced session reuses the same
150
- * runtime. A shutdown clears the trigger before the runtime releases the table it
151
- * was calling into.
150
+ * runtime, so a runtime that can settle an outcome while loading holds what it
151
+ * settled until this arms. A shutdown clears the trigger before the runtime
152
+ * releases the table it was calling into.
152
153
  */
153
154
  export function registerPendingChannel(
154
155
  pi: Pick<ExtensionAPI, "on">,
@@ -322,8 +322,8 @@ export interface ProbeSeams {
322
322
  readJson: (path: string) => JsonReadResult;
323
323
  dirs: DirSeams;
324
324
  platform: NodeJS.Platform;
325
- /** Whether the package will actually load this extension this session. */
326
- isEnabled: (extension: string) => boolean;
325
+ /** Why this session is not loading the extension, or undefined when it loads it. */
326
+ disabledReason: (extension: string) => string | undefined;
327
327
  /** Load an optional package; defaults to a real dynamic import. */
328
328
  loadModule?: (name: string) => Promise<unknown>;
329
329
  /** Resolve the nested-pi command; defaults to {@link getPiInvocation}. */
@@ -718,11 +718,11 @@ function configFindings(extension: string, spec: ExtensionRequirements, seams: P
718
718
  /** Probe one extension against its declaration and the current environment. */
719
719
  export async function probeExtension(extension: string, seams: ProbeSeams): Promise<ExtensionReport> {
720
720
  const spec = REQUIREMENTS[extension];
721
- const enabled = seams.isEnabled(extension);
721
+ const disabledReason = seams.disabledReason(extension);
722
722
  if (!spec) {
723
723
  return {
724
724
  extension,
725
- enabled,
725
+ enabled: disabledReason === undefined,
726
726
  findings: [
727
727
  {
728
728
  extension,
@@ -733,16 +733,16 @@ export async function probeExtension(extension: string, seams: ProbeSeams): Prom
733
733
  ],
734
734
  };
735
735
  }
736
- if (!enabled) {
736
+ if (disabledReason !== undefined) {
737
737
  return {
738
738
  extension,
739
- enabled,
739
+ enabled: false,
740
740
  findings: [
741
741
  {
742
742
  extension,
743
743
  check: "state",
744
744
  severity: "info",
745
- detail: "disabled through PI_DISABLED_EXTENSIONS",
745
+ detail: disabledReason,
746
746
  },
747
747
  ],
748
748
  };
@@ -755,7 +755,7 @@ export async function probeExtension(extension: string, seams: ProbeSeams): Prom
755
755
  for (const note of spec.notes ?? []) {
756
756
  findings.push({ extension, check: "platform", severity: "info", detail: note });
757
757
  }
758
- return { extension, enabled, findings };
758
+ return { extension, enabled: true, findings };
759
759
  }
760
760
 
761
761
  /** Probes in flight at once: enough to hide spawn latency, not a burst. */
@@ -4,11 +4,11 @@
4
4
  * Two vocabularies share this module, and neither copies the other. `session-bus`
5
5
  * settles its own label for a session that did not ask for one — the Pi session name,
6
6
  * else the working directory's basename, else the id. The board names a session for
7
- * the team instead: the page's seat, or `team-agent-` and the id's tail, which is the
8
- * same name on every surface and cannot drift when a user renames a session. The rules
9
- * are here rather than in either extension because a label is a shared vocabulary: it
10
- * is displayed, it is a send target, and it is stored in a card file that outlives one
11
- * session.
7
+ * the team instead: the page's seat, or a preset teammate name drawn from the id, or
8
+ * `team-agent-` and the id's tail when every preset one is taken — one name on every
9
+ * surface, and one that cannot drift on a rename. The rules are here rather than in
10
+ * either extension because a label is a shared vocabulary: it is displayed, it is a
11
+ * send target, and it is stored in a card file that outlives one session.
12
12
  *
13
13
  * Value-only: no state, no I/O.
14
14
  */
@@ -64,6 +64,70 @@ export function agentLabel(id: string): string {
64
64
  return normalizeLabel(`${TEAM_AGENT_PREFIX}${shortId(id)}`, "team-agent");
65
65
  }
66
66
 
67
+ /**
68
+ * The preset teammate names a session with no name of its own draws from.
69
+ *
70
+ * Familiar English names, one or two syllables each, short enough to type as a `to`
71
+ * target: a teammate should read as a person, and `lily` says more than
72
+ * `team-agent-9f21ac` to the lead who hands it a card.
73
+ */
74
+ export const TEAMMATE_NAMES = [
75
+ "tom",
76
+ "amy",
77
+ "lily",
78
+ "jack",
79
+ "lucy",
80
+ "mike",
81
+ "kate",
82
+ "sam",
83
+ "bob",
84
+ "anna",
85
+ "helen",
86
+ "peter",
87
+ "alice",
88
+ "emma",
89
+ "jenny",
90
+ "mary",
91
+ "linda",
92
+ "susan",
93
+ "tina",
94
+ "ben",
95
+ "may",
96
+ "rose",
97
+ "david",
98
+ "leo",
99
+ ] as const;
100
+
101
+ /** A 32-bit FNV-1a hash, so one id always starts the draw at the same name. */
102
+ function hashOf(text: string): number {
103
+ let hash = 0x811c9dc5;
104
+ for (let index = 0; index < text.length; index += 1) {
105
+ hash ^= text.charCodeAt(index);
106
+ hash = Math.imul(hash, 0x01000193);
107
+ }
108
+ return hash >>> 0;
109
+ }
110
+
111
+ /**
112
+ * The preset name a session with none of its own joins under.
113
+ *
114
+ * Its id hashes to a starting point in the list, and a name in `taken` walks on to the
115
+ * next free one; every name taken is `undefined`, so the caller keeps a fallback that
116
+ * cannot collide. Drawn from the id rather than at random, because the board writes a
117
+ * card before it joins: one session has to draw one name on every call and after a
118
+ * restart, so the card, the join, and the roster say the same word. `taken` is
119
+ * compared case-insensitively, the way a label is addressed.
120
+ */
121
+ export function presetNameFor(id: string, taken: Iterable<string> = []): string | undefined {
122
+ const held = new Set([...taken].map((label) => label.toLowerCase()));
123
+ const start = hashOf(id) % TEAMMATE_NAMES.length;
124
+ for (let step = 0; step < TEAMMATE_NAMES.length; step += 1) {
125
+ const name = TEAMMATE_NAMES[(start + step) % TEAMMATE_NAMES.length];
126
+ if (!held.has(name)) return name;
127
+ }
128
+ return undefined;
129
+ }
130
+
67
131
  /**
68
132
  * Pi's session name, which is the first choice for a label.
69
133
  *
@@ -78,3 +142,45 @@ export function sessionNameOf(ctx: Pick<ExtensionContext, "sessionManager">): st
78
142
  return undefined;
79
143
  }
80
144
  }
145
+
146
+ /**
147
+ * The session's id, or undefined when the host does not report one.
148
+ *
149
+ * Read beside the name because they are the same fact from two sides: a name is how a
150
+ * person reads a session, an id is how one is filed — a bus record, a card's owner, a
151
+ * resume. A host that refuses the call is a session with no id, not an error.
152
+ */
153
+ export function sessionIdOf(ctx: Pick<ExtensionContext, "sessionManager">): string | undefined {
154
+ try {
155
+ const id = ctx.sessionManager.getSessionId();
156
+ return typeof id === "string" && id !== "" ? id : undefined;
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * `pi.events` channel: a session settled the label its peers address it by.
164
+ *
165
+ * Here rather than in `lib/events.ts`, the way `lib/plan-entry.ts` and `lib/http/mount.ts`
166
+ * keep their own: a label is this module's vocabulary, and both sides of the channel — the
167
+ * bus that settles one and the footer that draws it — already load this module, so the
168
+ * literal costs a registration graph nothing and cannot be re-typed on one side.
169
+ */
170
+ export const SESSION_LABEL_CHANNEL = "my-pi-agent/session-label";
171
+
172
+ /**
173
+ * Payload on {@link SESSION_LABEL_CHANNEL}: one session's name on the bus.
174
+ *
175
+ * Emitted by `session-bus` whenever it settles or yields a label, and read by bytefly's
176
+ * footer, which draws it instead of Pi's own session name: the two part company the moment
177
+ * the board seats a session under a preset teammate name, or a collision appends an id. The
178
+ * id rides along because the channel reaches every session in the process, and only the one
179
+ * named here is the reader's own.
180
+ */
181
+ export interface SessionLabelEvent {
182
+ /** Pi session id the label belongs to. */
183
+ id: string;
184
+ /** The name another session addresses this one by. */
185
+ label: string;
186
+ }