@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
package/lib/http/sink.ts CHANGED
@@ -22,12 +22,23 @@ export interface EventSink {
22
22
  end(frame?: string): void;
23
23
  }
24
24
 
25
+ /** Why a server went idle: no stream ever opened, or the last one left. */
26
+ export type IdleReason = "first" | "last";
27
+
25
28
  /** The idle policy a server was given, or none. */
26
29
  export interface IdleGrace {
27
30
  /** How long the last stream's departure is held, in ms. */
28
31
  ms: number;
29
- /** Called once no stream has been open for `ms`. */
30
- onIdle: () => void;
32
+ /**
33
+ * How long a server may stand with no stream ever opened, in ms.
34
+ *
35
+ * A page opened in a browser that never came up would otherwise hold its port for the
36
+ * rest of the session. Omitted, the grace still waits for the first stream, which is
37
+ * what a server whose page is opened by hand wants.
38
+ */
39
+ firstMs?: number;
40
+ /** Called once no stream has been open for the grace that applied. */
41
+ onIdle: (reason: IdleReason) => void;
31
42
  }
32
43
 
33
44
  /** What the registry needs besides the streams themselves. */
@@ -44,6 +55,8 @@ export interface SinkRegistry {
44
55
  open(response: ServerResponse): EventSink;
45
56
  /** How many streams are open. */
46
57
  size(): number;
58
+ /** Arm the grace for a server nobody may ever read. Does nothing without `firstMs`. */
59
+ start(): void;
47
60
  /** End every open stream, because the server is closing. */
48
61
  endAll(): void;
49
62
  }
@@ -52,20 +65,24 @@ export function createSinks(deps: SinkDeps = {}): SinkRegistry {
52
65
  const streams = new Set<EventSink>();
53
66
  const timer = deps.timer ?? defaultTimer;
54
67
  let cancelIdle: (() => void) | undefined;
55
- /** A grace armed before any page ever attached would close a server nobody opened. */
68
+ /** A stream has attached at least once, so a departure is one to wait on. */
56
69
  let sawStream = false;
70
+ /** The server asked for the first grace, so it may go idle without a reader. */
71
+ let started = false;
57
72
 
58
73
  const standDown = (): void => {
59
74
  cancelIdle?.();
60
75
  cancelIdle = undefined;
61
76
  };
62
77
 
63
- const arm = (): void => {
64
- const idle = deps.idle;
65
- if (idle === undefined || !sawStream || streams.size > 0 || cancelIdle !== undefined) return;
66
- cancelIdle = timer(idle.ms, () => {
78
+ /** Arm `ms` from now, unless a stream is open or a grace already runs. */
79
+ const arm = (ms: number, reason: IdleReason): void => {
80
+ if (deps.idle === undefined || (!started && !sawStream) || streams.size > 0 || cancelIdle !== undefined) return;
81
+ cancelIdle = timer(ms, () => {
67
82
  cancelIdle = undefined;
68
- if (streams.size === 0) deps.idle?.onIdle();
83
+ // A tab that attached while the grace ran has already stood it down, but a
84
+ // second look costs nothing and keeps a race from closing a page being read.
85
+ if (streams.size === 0) deps.idle?.onIdle(reason);
69
86
  });
70
87
  };
71
88
 
@@ -74,12 +91,20 @@ export function createSinks(deps: SinkDeps = {}): SinkRegistry {
74
91
  standDown();
75
92
  sawStream = true;
76
93
  const sink = createSink(response, () => {
77
- if (streams.delete(sink)) arm();
94
+ if (!streams.delete(sink)) return;
95
+ const idle = deps.idle;
96
+ if (idle !== undefined) arm(idle.ms, "last");
78
97
  });
79
98
  streams.add(sink);
80
99
  return sink;
81
100
  },
82
101
  size: () => streams.size,
102
+ start() {
103
+ const idle = deps.idle;
104
+ if (idle?.firstMs === undefined || started || sawStream) return;
105
+ started = true;
106
+ arm(idle.firstMs, "first");
107
+ },
83
108
  endAll() {
84
109
  standDown();
85
110
  const open = [...streams];
package/lib/plan-entry.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { type BusAnswer, requestOverBus } from "./bus-request.ts";
19
20
  import { isPlanModeEnabled } from "./plan-state.ts";
20
21
  import { PLAN_ENTER_TOOL } from "./tool-names.ts";
21
22
 
@@ -41,12 +42,23 @@ export const PLAN_CONSENT_SHORT =
41
42
  "Enter read-only plan mode? Writes and mutating tools are disabled until you approve a plan.";
42
43
  export const PLAN_CONSENT_SHORT_ZH = "进入只读 plan 模式?批准计划前会禁用写入和修改。";
43
44
 
45
+ /**
46
+ * The refusal a command shows when plan mode forbids what it does.
47
+ *
48
+ * Plan mode is read-only, so every extension whose command changes something says so the
49
+ * same way and names the action it is not doing: one sentence in `lib/`, because five
50
+ * copies is how the guarantee stops reading as one guarantee.
51
+ */
52
+ export function planModeRefusal(action: string): string {
53
+ return `Plan mode is read-only. Approve the plan before ${action}.`;
54
+ }
55
+
44
56
  /** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
45
57
  export interface PlanEnterRequest {
46
58
  /** The caller's context; `plan` uses it for the footer status. */
47
59
  ctx: ExtensionContext;
48
- /** Called once, synchronously, with whether plan mode is on. */
49
- respond(entered: boolean): void;
60
+ /** Claimed once, with whether plan mode is on; the caller settles on that claim. */
61
+ respond(entered: BusAnswer<boolean>): void;
50
62
  }
51
63
 
52
64
  /**
@@ -54,8 +66,8 @@ export interface PlanEnterRequest {
54
66
  *
55
67
  * Resolves `true` without asking when the branch is already planning, and
56
68
  * `false` when the `plan` tool is not registered — so `goal` loaded alone keeps
57
- * working and nothing waits on a listener that is not there. The listener answers
58
- * synchronously; the microtask is the fallback that guarantees resolution.
69
+ * working and nothing waits on a listener that is not there. The listener claims
70
+ * synchronously; the shared handshake is what guarantees a resolution.
59
71
  */
60
72
  export function requestPlanMode(
61
73
  pi: Pick<ExtensionAPI, "events" | "getAllTools">,
@@ -63,14 +75,9 @@ export function requestPlanMode(
63
75
  ): Promise<boolean> {
64
76
  if (isPlanModeEnabled(ctx.sessionManager.getBranch())) return Promise.resolve(true);
65
77
  if (!pi.getAllTools().some((tool) => tool.name === PLAN_ENTER_TOOL)) return Promise.resolve(false);
66
- return new Promise<boolean>((resolve) => {
67
- let settled = false;
68
- const respond = (entered: boolean): void => {
69
- if (settled) return;
70
- settled = true;
71
- resolve(entered);
72
- };
73
- pi.events.emit(PLAN_ENTER_REQUEST, { ctx, respond } satisfies PlanEnterRequest);
74
- queueMicrotask(() => respond(false));
78
+ return requestOverBus<boolean, PlanEnterRequest>(pi, {
79
+ channel: PLAN_ENTER_REQUEST,
80
+ payload: (respond) => ({ ctx, respond }),
81
+ unclaimed: () => false,
75
82
  });
76
83
  }
package/lib/policy.ts CHANGED
@@ -15,7 +15,20 @@
15
15
  */
16
16
 
17
17
  import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
18
- import { READ_DOC_TOOL } from "./tool-names.ts";
18
+ import { BOARD_READ_ACTION, READ_DOC_TOOL } from "./tool-names.ts";
19
+
20
+ /**
21
+ * Whether a call to the `board` tool only reads it.
22
+ *
23
+ * The board is one tool over ten actions, so its `readOnlyHint` says "may write" and
24
+ * cannot answer this. The action can, and it travels in the call's input — and in the
25
+ * 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
+ */
28
+ export function isBoardRead(call: unknown): boolean {
29
+ if (typeof call !== "object" || call === null) return false;
30
+ return (call as { action?: unknown }).action === BOARD_READ_ACTION;
31
+ }
19
32
 
20
33
  /**
21
34
  * Tools that only read the workspace: the built-in structured readers and the
@@ -208,6 +208,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
208
208
  },
209
209
  todo: {},
210
210
  goal: {},
211
+ serve: { notes: ["Without it `/board`, `/browse`, and `/plan review` refuse, naming it."] },
211
212
  board: {},
212
213
  team: { notes: ["`assign` needs `session-bus` loaded to deliver a handoff; without it the card is untouched."] },
213
214
  check: {
@@ -256,8 +257,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
256
257
  "file-open": {
257
258
  notes: [
258
259
  "The `/open` command and its notification need an interactive UI; the `open_path` tool works without one.",
259
- "Launching the OS handler is best-effort: a launcher that fails to start, or exits non-zero on macOS and Linux, is reported; Windows `explorer` can exit non-zero after a successful open.",
260
- "The tool result names the target even when the launch fails.",
260
+ "Launching the OS handler is best-effort — a launcher that fails to start, or exits non-zero on macOS and Linux, is reported, Windows `explorer` can exit non-zero after a successful open, and the tool result names the target either way.",
261
261
  ],
262
262
  },
263
263
  memory: {
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * The session label: the one name another session addresses this one by.
3
3
  *
4
- * `session-bus` settles a label for every session — the Pi session name, else the
5
- * working directory's basename, else the id — and the board records the same name
6
- * on a card it claims, so the two surfaces agree on who "billing-core" is without
7
- * the board reading the bus. The rules are here rather than in either extension
8
- * because a label is a shared vocabulary: it is displayed, it is a send target, and
9
- * it is stored in a card file that outlives one session.
4
+ * Two vocabularies share this module, and neither copies the other. `session-bus`
5
+ * settles its own label for a session that did not ask for one — the Pi session name,
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.
10
12
  *
11
13
  * Value-only: no state, no I/O.
12
14
  */
@@ -42,12 +44,26 @@ export function shortId(id: string): string {
42
44
  * The bus appends a suffix when two live sessions share one; that decision belongs
43
45
  * to the registry, so this is only the name a session would pick for itself.
44
46
  */
45
- export function defaultLabel(sessionName: string | undefined, cwd: string, id: string): string {
47
+ export function defaultLabel(sessionName: string | undefined, cwd: string, id = "session"): string {
46
48
  const directory = cwd.split(/[\\/]/).filter(Boolean).at(-1);
47
49
  const base = normalizeLabel(sessionName, normalizeLabel(directory, "session"));
48
50
  return normalizeLabel(base, `session-${shortId(id)}`);
49
51
  }
50
52
 
53
+ /** The prefix of the team's name for a session that is not the page's seat. */
54
+ export const TEAM_AGENT_PREFIX = "team-agent-";
55
+
56
+ /**
57
+ * The team's name for a session: the prefix and the id's tail.
58
+ *
59
+ * A team is addressed by id, not by a name a user chose: an id is unique, so two
60
+ * sessions cannot settle on one name, and a rename cannot strand the card that
61
+ * recorded it. The seat is the one name that is a role instead.
62
+ */
63
+ export function agentLabel(id: string): string {
64
+ return normalizeLabel(`${TEAM_AGENT_PREFIX}${shortId(id)}`, "team-agent");
65
+ }
66
+
51
67
  /**
52
68
  * Pi's session name, which is the first choice for a label.
53
69
  *
@@ -9,6 +9,10 @@
9
9
  * here once, and `session-bus` imports them, so a layout change cannot land in one
10
10
  * copy and not the other.
11
11
  *
12
+ * The rule answers three things, not two: a record whose process is gone is `gone` at
13
+ * once, rather than aging into `quiet` for ten minutes while the paths it claimed stay
14
+ * held and the cards it held read `idle`.
15
+ *
12
16
  * A missing directory is an answer, not an error: it means no session has ever
13
17
  * joined this repository, and a caller renders that differently from an empty bus.
14
18
  *
@@ -18,6 +22,7 @@
18
22
 
19
23
  import { readdir, readFile } from "node:fs/promises";
20
24
  import { join } from "node:path";
25
+ import { isPidAlive, type LivenessFn, startTokenOf, type StartTokenFn } from "./spawn.ts";
21
26
 
22
27
  /** The bus directory under the agent dir, `session-bus`. */
23
28
  export const BUS_DIR_NAME = "session-bus";
@@ -37,6 +42,16 @@ export interface RosterSession {
37
42
  lastSeen: number;
38
43
  /** Messages waiting in its inbox. */
39
44
  mail: number;
45
+ /**
46
+ * The process that owns the record, when the record names one.
47
+ *
48
+ * Absent is "cannot tell": a record written before this existed, or by a mode that
49
+ * is not a local process, is judged by its heartbeat alone rather than being called
50
+ * dead on no evidence.
51
+ */
52
+ pid?: number;
53
+ /** That process's start time, as the bus wrote it, when the record carries it. */
54
+ startToken?: string;
40
55
  /**
41
56
  * Whether the session is listening for mail, when its record says.
42
57
  *
@@ -46,9 +61,56 @@ export interface RosterSession {
46
61
  watching?: { armed: boolean; wake: boolean };
47
62
  }
48
63
 
49
- /** Whether a record's heartbeat is fresh enough to call the session live. */
50
- export function rosterStateOf(session: RosterSession, now: number, ttlMs = SESSION_TTL_MS): "live" | "quiet" {
51
- return now - session.lastSeen <= ttlMs ? "live" : "quiet";
64
+ /** What a record's heartbeat and its process say together. */
65
+ export type RosterState = "live" | "quiet" | "gone";
66
+
67
+ /**
68
+ * What a record's heartbeat and its process say together.
69
+ *
70
+ * The fields the rule reads, rather than the whole roster row: `session-bus` judges its
71
+ * own records by the same rule without carrying a `mail` count it never shows.
72
+ */
73
+ export type RosterFacts = Pick<RosterSession, "lastSeen" | "pid" | "startToken">;
74
+
75
+ /**
76
+ * The host facts {@link rosterStateOf} reads, injectable so every branch is testable.
77
+ *
78
+ * The token spawns a process, so it is asked for only where liveness cannot decide.
79
+ */
80
+ export interface RosterSeams {
81
+ alive?: LivenessFn;
82
+ token?: StartTokenFn;
83
+ }
84
+
85
+ /**
86
+ * What one record says about its session: alive, aged out, or attached to nothing.
87
+ *
88
+ * Three answers, in the order they are decided:
89
+ *
90
+ * - `gone` — the record names a process and that process is not running. A heartbeat
91
+ * written ten seconds before a crash is not evidence the process exists, so death
92
+ * outranks a fresh one.
93
+ * - `live` — the heartbeat is fresh. When the pid is alive too, the start token has not
94
+ * been asked for yet: a session that wrote ten seconds ago is not the interesting
95
+ * case for pid reuse, and `ps` costs a process.
96
+ * - `quiet` — the heartbeat has aged. If the pid is alive but its start time is not the
97
+ * one the record wrote, the process is somebody else's and the session is `gone`
98
+ * after all; a platform that cannot answer the start time leaves it at `quiet`.
99
+ */
100
+ export function rosterStateOf(
101
+ session: RosterFacts,
102
+ now: number,
103
+ ttlMs = SESSION_TTL_MS,
104
+ seams: RosterSeams = {},
105
+ ): RosterState {
106
+ const alive = seams.alive ?? isPidAlive;
107
+ if (session.pid !== undefined && !alive(session.pid)) return "gone";
108
+ if (now - session.lastSeen <= ttlMs) return "live";
109
+ if (session.pid !== undefined && session.startToken !== undefined) {
110
+ const current = (seams.token ?? startTokenOf)(session.pid);
111
+ if (current !== undefined && current !== session.startToken) return "gone";
112
+ }
113
+ return "quiet";
52
114
  }
53
115
 
54
116
  /** The fields this reader trusts; anything else in the file is the bus's business. */
@@ -59,6 +121,11 @@ interface SessionPayload {
59
121
  lastSeen: number;
60
122
  }
61
123
 
124
+ /** A record's pid, read only when it is the shape a pid has. */
125
+ function pidOf(value: unknown): number | undefined {
126
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
127
+ }
128
+
62
129
  function isSessionPayload(value: unknown): value is SessionPayload {
63
130
  if (typeof value !== "object" || value === null) return false;
64
131
  const { id, label, mode, lastSeen } = value as Record<string, unknown>;
@@ -117,12 +184,16 @@ export async function readRoster(dir: string): Promise<RosterSession[] | undefin
117
184
  if (!isSessionPayload(parsed)) continue;
118
185
  const { id, label, mode, lastSeen } = parsed;
119
186
  const watching = watchingOf((parsed as { watching?: unknown }).watching);
187
+ const pid = pidOf((parsed as { pid?: unknown }).pid);
188
+ const rawToken = (parsed as { startToken?: unknown }).startToken;
120
189
  sessions.push({
121
190
  id,
122
191
  label,
123
192
  mode,
124
193
  lastSeen,
125
194
  mail: await mailCount(dir, name.slice(0, -".json".length)),
195
+ ...(pid === undefined ? {} : { pid }),
196
+ ...(typeof rawToken === "string" && rawToken !== "" ? { startToken: rawToken } : {}),
126
197
  ...(watching === undefined ? {} : { watching }),
127
198
  });
128
199
  }
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { ExtensionAPI, ExtensionContext, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
25
+ import { type BusAnswer, requestOverBus } from "./bus-request.ts";
25
26
  import { messageOf } from "./errors.ts";
26
27
  import { watchingOf } from "./session-roster.ts";
27
28
  import { SESSION_TOOL } from "./tool-names.ts";
@@ -54,14 +55,29 @@ export type JoinResult = { ok: true; label: string; watching: Watching } | { ok:
54
55
  /** EventBus channel on which a caller asks `session-bus` to join the team for it. */
55
56
  export const TEAM_JOIN_REQUEST = "my-pi-agent/team-join/request";
56
57
 
58
+ /**
59
+ * What a join asks for.
60
+ *
61
+ * `label` is the name to be on the team as; without one the bus settles its own. `name`
62
+ * is the separate question of whether that label also becomes Pi's session name — the
63
+ * footer and `/resume` — and it is opt-in, because a join is not a rename: only
64
+ * `/team join <label>` means "call me this from now on".
65
+ */
66
+ export interface TeamJoinInput {
67
+ label?: string;
68
+ wake?: boolean;
69
+ listen?: boolean;
70
+ name?: boolean;
71
+ }
72
+
57
73
  /** What a caller sends on {@link TEAM_JOIN_REQUEST}. */
58
74
  export interface TeamJoinRequest {
59
75
  /** The caller's context; the bus starts and names through it. */
60
76
  ctx: ExtensionContext;
61
77
  /** What to join as and how to listen. */
62
- input: { label?: string; wake?: boolean; listen?: boolean };
63
- /** Called once, when the bus has answered. */
64
- respond(result: JoinResult): void;
78
+ input: TeamJoinInput;
79
+ /** Claimed once, with what the bus answered; the caller settles on that claim. */
80
+ respond(result: BusAnswer<JoinResult>): void;
65
81
  }
66
82
 
67
83
  /** What a send did. */
@@ -96,49 +112,49 @@ function nestedMessage(result: { content?: Array<{ type: string; text?: string }
96
112
  /**
97
113
  * How an extension joins the team, whatever kind of context it is holding.
98
114
  *
99
- * Two extensions ask — `board` when the page opens or a card is worked, `team` for
115
+ * Two extensions ask — `board` when the page opens or a card is claimed, `team` for
100
116
  * `/team join` — and both hold a command or a tool context, so the request is the one
101
117
  * transport that works from either.
102
118
  */
103
119
  export interface TeamJoin {
104
- /** Join the team, naming this session when `label` is given. Never throws. */
105
- join(ctx: ExtensionContext, label?: string): Promise<JoinResult>;
120
+ /** Join the team as `input.label` when given. Never throws. */
121
+ join(ctx: ExtensionContext, input?: TeamJoinInput): Promise<JoinResult>;
106
122
  }
107
123
 
108
124
  /** The production join, bound to one extension's `pi`. */
109
125
  export function createTeamJoin(pi: Pick<ExtensionAPI, "events" | "getAllTools">): TeamJoin {
110
126
  return {
111
- join: (ctx, label) => join(pi, ctx, label === undefined ? {} : { label }),
127
+ join: (ctx, input) => join(pi, ctx, input ?? {}),
112
128
  };
113
129
  }
114
130
 
131
+ /** What a caller is told when there is no bus to join: one sentence on both paths. */
132
+ const NO_BUS = "the session bus is not loaded";
133
+
115
134
  /**
116
135
  * Join the team: on the bus, named when a label is given, and listening for mail.
117
136
  *
118
137
  * One call for all three, because that is one act — see `join` in
119
138
  * `extensions/session-bus` — and one transport for both kinds of caller: the request
120
- * travels on `pi.events` and the bus answers it.
139
+ * travels on `pi.events` and the bus answers it. Pi's session name is not one of the
140
+ * three: that is `input.name`, which only a caller meaning to rename asks for.
121
141
  *
122
142
  * With no bus loaded there is no listener, so the answer is the sentence the caller
123
143
  * reports rather than a promise that never settles: the `session` tool's presence is
124
- * the same check `lib/plan-entry.ts` makes, and the listener always answers.
144
+ * checked here, and the shared handshake refuses the same way if a bus is loaded but
145
+ * listening for nothing.
125
146
  */
126
147
  export async function join(
127
148
  pi: Pick<ExtensionAPI, "events" | "getAllTools">,
128
149
  ctx: ExtensionContext,
129
- input: { label?: string; wake?: boolean; listen?: boolean } = {},
150
+ input: TeamJoinInput = {},
130
151
  ): Promise<JoinResult> {
131
- if (!pi.getAllTools().some((tool) => tool.name === SESSION_TOOL)) {
132
- return { ok: false, error: "the session bus is not loaded" };
133
- }
134
- return new Promise<JoinResult>((resolve) => {
135
- let settled = false;
136
- const respond = (result: JoinResult): void => {
137
- if (settled) return;
138
- settled = true;
139
- resolve(result);
140
- };
141
- pi.events.emit(TEAM_JOIN_REQUEST, { ctx, input, respond } satisfies TeamJoinRequest);
152
+ if (!pi.getAllTools().some((tool) => tool.name === SESSION_TOOL)) return { ok: false, error: NO_BUS };
153
+ return requestOverBus<JoinResult, TeamJoinRequest>(pi, {
154
+ channel: TEAM_JOIN_REQUEST,
155
+ payload: (respond) => ({ ctx, input, respond }),
156
+ unclaimed: () => ({ ok: false, error: NO_BUS }),
157
+ failed: (error) => ({ ok: false, error: messageOf(error) }),
142
158
  });
143
159
  }
144
160
 
package/lib/spawn.ts CHANGED
@@ -8,11 +8,14 @@
8
8
  *
9
9
  * IO is split from the decision: {@link spawnOptions} and {@link killPlan} are
10
10
  * pure and testable on every platform, while {@link defaultSpawn} and
11
- * {@link defaultKillTree} execute them. Value-only, like every `lib/` module:
12
- * no module state, and each caller keeps its own children.
11
+ * {@link defaultKillTree} execute them. The liveness pair — {@link isPidAlive}
12
+ * and {@link startTokenOf} — is here too, because asking whether a process is
13
+ * still the one a record named is the same question as killing it: `job` guards
14
+ * a reattached pid with it, and so does the session roster. Value-only, like
15
+ * every `lib/` module: no module state, and each caller keeps its own children.
13
16
  */
14
17
 
15
- import { spawn as nodeSpawn, spawnSync } from "node:child_process";
18
+ import { execFileSync, spawn as nodeSpawn, spawnSync } from "node:child_process";
16
19
  import type { Readable } from "node:stream";
17
20
 
18
21
  /**
@@ -124,6 +127,37 @@ export function isPidAlive(pid: number, probe: KillProbe = process.kill): boolea
124
127
  }
125
128
  }
126
129
 
130
+ /** Whether a pid is still alive. */
131
+ export type LivenessFn = (pid: number) => boolean;
132
+
133
+ /**
134
+ * Best-effort OS start-time token for a pid, used to detect pid reuse.
135
+ *
136
+ * Returns undefined when the platform cannot provide one (for example Windows, or a
137
+ * process that is already gone), in which case callers fall back to liveness alone.
138
+ */
139
+ export type StartTokenFn = (pid: number) => string | undefined;
140
+
141
+ /**
142
+ * Default start-time token via `ps`. Undefined where `ps` is unavailable.
143
+ *
144
+ * It spawns a process, so a caller asks for it only where liveness alone cannot
145
+ * decide: a pid that is alive may be a different program than the one a record
146
+ * named, and nothing cheaper than the start time can tell them apart.
147
+ */
148
+ export const startTokenOf: StartTokenFn = (pid) => {
149
+ try {
150
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
151
+ stdio: ["ignore", "pipe", "ignore"],
152
+ timeout: 2000,
153
+ });
154
+ const text = out.toString().trim();
155
+ return text || undefined;
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ };
160
+
127
161
  /** The host seams {@link applyKillPlan} runs through. */
128
162
  export interface KillSeams {
129
163
  /** Signal one pid; throws when the pid is gone or not ours. */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A field a tool call carries but its action does not act on.
3
+ *
4
+ * An action-shaped tool publishes one flat parameter object, so every field is
5
+ * offered to every action at once; that is harmless only while each action reads
6
+ * its own. A field that reaches an action which never reads it used to be dropped
7
+ * in silence — `board add` with `dependsOn` wrote the card without the link and
8
+ * answered success, and `team offer` with `to` broadcast to every live session
9
+ * while the caller believed it had named one. The caller could not tell what was
10
+ * recorded from what was ignored, and nothing in the result said which.
11
+ *
12
+ * So the table of what each action reads lives with the tool (`ACTION_FIELDS`),
13
+ * and this is the sentence a call is refused with: it names every stray field and
14
+ * the action that does take it, which makes the retry one call away. The table is
15
+ * keyed in the order the actions are documented, because that order is how the
16
+ * message lists the verbs that take a field.
17
+ *
18
+ * Value-only, as `lib/` must be: the tables are the callers'.
19
+ */
20
+
21
+ /** The fields each action reads, keyed in the order the actions are documented. */
22
+ export type ActionFields = Readonly<Record<string, readonly string[]>>;
23
+
24
+ /**
25
+ * Why a call carries a field its action does not act on, or nothing when it is clean.
26
+ *
27
+ * `undefined` is not a stray: an optional field the caller left out was never passed,
28
+ * and a schema makes every unset one `undefined`.
29
+ */
30
+ export function strayField(action: string, args: Record<string, unknown>, fields: ActionFields): string | undefined {
31
+ const mine = fields[action] ?? [];
32
+ const stray = Object.entries(args)
33
+ .filter(([field, value]) => value !== undefined && field !== "action" && !mine.includes(field))
34
+ .map(([field]) => field);
35
+ if (stray.length === 0) return undefined;
36
+ // One stray reads as "takes it"; several need their names, or the sentence stops
37
+ // saying which field goes where.
38
+ const named = (field: string): string => {
39
+ const taking = Object.keys(fields).filter((candidate) => fields[candidate]?.includes(field));
40
+ if (taking.length === 0) return `${field} belongs to no action`;
41
+ const verbs = taking.map((one) => `\`${one}\``).join(" and ");
42
+ return `${verbs} ${taking.length === 1 ? "takes" : "take"} ${stray.length === 1 ? "it" : field}`;
43
+ };
44
+ return `${action} does not act on ${stray.join(", ")}: ${stray.map(named).join("; ")}.`;
45
+ }
package/lib/tool-names.ts CHANGED
@@ -45,6 +45,19 @@ export const SESSION_TOOL = "session";
45
45
  */
46
46
  export const SUBAGENT_READ_ONLY_PARAM = "readOnly";
47
47
 
48
+ /**
49
+ * The `board` tool: the shared feature list. Plan mode keeps it active because its
50
+ * `list` action is a read of the repository's cards, so the name is shared while the
51
+ * write actions stay the board's to refuse.
52
+ */
53
+ export const BOARD_TOOL = "board";
54
+
55
+ /**
56
+ * The one `board` action that reads. Plan mode admits it and refuses the rest, which
57
+ * is the distinction the tool's `readOnlyHint` cannot carry: the same tool writes.
58
+ */
59
+ export const BOARD_READ_ACTION = "list";
60
+
48
61
  /**
49
62
  * The `read_doc` tool. `file-read` registers it, and the plan, rewind, and
50
63
  * subagent reader lists carry the name, so the document reader is one reader
package/lib/tui/README.md CHANGED
@@ -160,9 +160,12 @@ hold themed strings in state unless `invalidate()` rebuilds them. A widget is
160
160
  derived from state, never authoritative.
161
161
 
162
162
  Pi renders the whole tree every frame, so a component that derives its lines from
163
- another one memoizes them with `RenderCache` (keyed on width and the source
164
- lines' identity) and drops the memo in `invalidate()`. Re-deriving every line per
165
- frame is what makes a long transcript feel slow.
163
+ another one memoizes them with `RenderCache` — `lines()` keyed on width and the
164
+ source lines' identity, or `keyed()` for a component with no inner component (a
165
+ header's view, a footer's snapshot), keyed on the parts it read: primitives by
166
+ value, an object such as the theme by identity — and drops the memo in
167
+ `invalidate()`. Re-deriving every line per frame is what makes a long transcript
168
+ feel slow.
166
169
 
167
170
  ## Focus
168
171
 
package/lib/tui/glyphs.ts CHANGED
@@ -29,8 +29,7 @@ export const GLYPHS = {
29
29
  jobsRunning: "▸",
30
30
  jobsFailure: "✗",
31
31
  stopped: "⊘",
32
- browse: "◉",
33
- board: "▤",
32
+ http: "◉",
34
33
  delegation: "↳",
35
34
  wechat: "✆",
36
35
  gaugeFull: "▰",
@@ -57,10 +56,9 @@ export const GLYPHS = {
57
56
  /** Columns between a rail body glyph and its text. */
58
57
  export const GLYPH_GAP = 1;
59
58
 
60
- /** Padding used to join (`item`) or group (`group`) UI segments. */
59
+ /** Padding used to join UI segments. */
61
60
  export const SEPARATORS = {
62
61
  item: " · ",
63
- group: ` ${GLYPHS.divider} `,
64
62
  } as const;
65
63
 
66
64
  /** `ctx.ui.setStatus` keys; one per extension that publishes a chip. */
@@ -71,8 +69,7 @@ export const STATUS_KEYS = {
71
69
  jobsFailure: "jobs-failure",
72
70
  watch: "watch",
73
71
  watchMatches: "watch-matches",
74
- browse: "browse",
75
- board: "board",
72
+ http: "http",
76
73
  rewind: "rewind",
77
74
  agents: "agents",
78
75
  wechat: "wechat",