@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -0,0 +1,329 @@
1
+ /**
2
+ * The file-backed store for one repository's session bus.
3
+ *
4
+ * Every session writes its own record, every message is its own file, and every
5
+ * claim is an exclusive-create file. That keeps concurrent Pi processes off one
6
+ * shared lock while still making the common operations atomic. The store knows
7
+ * nothing about models or UI; it only reads and writes validated JSON.
8
+ */
9
+
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { mkdir, link, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
12
+ import { dirname, join } from "node:path";
13
+ import { asRecord, readJson } from "../../lib/config.ts";
14
+ import { claimsConflict } from "./protocol.ts";
15
+ import type { BusMessage, ClaimConflict, ClaimRecord, SessionRecord } from "./types.ts";
16
+
17
+ const DIR_MODE = 0o700;
18
+ const FILE_MODE = 0o600;
19
+
20
+ export interface BusStoreOptions {
21
+ /** The repository bus directory, e.g. `<agent-dir>/session-bus/<key>`. */
22
+ dir: string;
23
+ }
24
+
25
+ export interface BusStore {
26
+ readonly dir: string;
27
+ ensure(): Promise<void>;
28
+ writeSession(record: SessionRecord): Promise<void>;
29
+ sessions(): Promise<SessionRecord[]>;
30
+ removeSession(id: string): Promise<void>;
31
+ writeMessage(target: string, message: BusMessage): Promise<void>;
32
+ /** Read a recipient's messages without consuming them, oldest first. */
33
+ peek(id: string, limit: number): Promise<BusMessage[]>;
34
+ inbox(id: string, limit: number): Promise<BusMessage[]>;
35
+ unread(id: string): Promise<number>;
36
+ claims(): Promise<ClaimRecord[]>;
37
+ claimAll(
38
+ paths: readonly string[],
39
+ owner: string,
40
+ ownerLabel: string,
41
+ at: number,
42
+ ): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
43
+ release(path: string, owner: string): Promise<boolean>;
44
+ releaseAll(owner: string): Promise<string[]>;
45
+ prune(input: {
46
+ now: number;
47
+ activeIds: ReadonlySet<string>;
48
+ sessionTtlMs: number;
49
+ messageTtlMs: number;
50
+ }): Promise<void>;
51
+ }
52
+
53
+ const SHA = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 32);
54
+
55
+ function isSession(value: unknown): value is SessionRecord {
56
+ const entry = asRecord(value);
57
+ if (!entry) return false;
58
+ return (
59
+ typeof entry.id === "string" &&
60
+ typeof entry.label === "string" &&
61
+ typeof entry.pid === "number" &&
62
+ typeof entry.cwd === "string" &&
63
+ typeof entry.repoRoot === "string" &&
64
+ typeof entry.mode === "string" &&
65
+ typeof entry.startedAt === "number" &&
66
+ typeof entry.lastSeen === "number"
67
+ );
68
+ }
69
+
70
+ function isMessage(value: unknown): value is BusMessage {
71
+ const entry = asRecord(value);
72
+ if (!entry) return false;
73
+ return (
74
+ typeof entry.id === "string" &&
75
+ typeof entry.from === "string" &&
76
+ typeof entry.fromLabel === "string" &&
77
+ typeof entry.to === "string" &&
78
+ typeof entry.toLabel === "string" &&
79
+ typeof entry.kind === "string" &&
80
+ typeof entry.text === "string" &&
81
+ typeof entry.at === "number" &&
82
+ (entry.paths === undefined || (Array.isArray(entry.paths) && entry.paths.every((path) => typeof path === "string")))
83
+ );
84
+ }
85
+
86
+ function isClaim(value: unknown): value is ClaimRecord {
87
+ const entry = asRecord(value);
88
+ if (!entry) return false;
89
+ return (
90
+ typeof entry.path === "string" &&
91
+ typeof entry.owner === "string" &&
92
+ typeof entry.ownerLabel === "string" &&
93
+ typeof entry.at === "number"
94
+ );
95
+ }
96
+
97
+ function readTyped<T>(path: string, guard: (value: unknown) => value is T): T | undefined {
98
+ const parsed = readJson(path);
99
+ return parsed !== undefined && guard(parsed) ? parsed : undefined;
100
+ }
101
+
102
+ async function writeJson(path: string, value: unknown): Promise<void> {
103
+ await mkdir(dirname(path), { recursive: true, mode: DIR_MODE });
104
+ const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
105
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf-8", mode: FILE_MODE });
106
+ try {
107
+ await rename(temp, path);
108
+ } catch {
109
+ // Windows cannot replace an existing file with rename; removing first keeps
110
+ // the write recoverable, and the window is only a heartbeat-sized race.
111
+ await rm(path, { force: true });
112
+ await rename(temp, path);
113
+ }
114
+ }
115
+
116
+ async function listFiles(dir: string): Promise<string[]> {
117
+ try {
118
+ return (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Drop a recipient's directory once it holds no files, so a bus does not keep
126
+ * one empty directory for every session that ever joined it. An inbox with mail
127
+ * stays, because a session's id survives a resume and its mail is still its own;
128
+ * non-recursive removal also loses the race to a message that lands meanwhile.
129
+ */
130
+ async function dropIfEmpty(dir: string): Promise<void> {
131
+ const entries = await readdir(dir).catch(() => null);
132
+ if (!entries || entries.length > 0) return;
133
+ await rmdir(dir).catch(() => {});
134
+ }
135
+
136
+ /** Create a store rooted at one repository's bus directory. */
137
+ export function createBusStore(options: BusStoreOptions): BusStore {
138
+ const { dir } = options;
139
+ const sessionsDir = join(dir, "sessions");
140
+ const inboxRoot = join(dir, "inbox");
141
+ const claimsDir = join(dir, "claims");
142
+
143
+ const sessionFile = (id: string): string => join(sessionsDir, `${SHA(id)}.json`);
144
+ const inboxDir = (id: string): string => join(inboxRoot, SHA(id));
145
+ const claimFile = (path: string): string => join(claimsDir, `${SHA(path)}.json`);
146
+
147
+ async function readSessions(): Promise<SessionRecord[]> {
148
+ const sessions: SessionRecord[] = [];
149
+ for (const name of await listFiles(sessionsDir)) {
150
+ const record = readTyped(join(sessionsDir, name), isSession);
151
+ if (record) sessions.push(record);
152
+ }
153
+ return sessions.sort((a, b) => b.lastSeen - a.lastSeen || a.label.localeCompare(b.label));
154
+ }
155
+
156
+ async function readMessages(id: string, limit: number, remove: boolean): Promise<BusMessage[]> {
157
+ const directory = inboxDir(id);
158
+ const messages: BusMessage[] = [];
159
+ for (const name of await listFiles(directory)) {
160
+ const path = join(directory, name);
161
+ const message = readTyped(path, isMessage);
162
+ if (message) messages.push(message);
163
+ if (remove || !message) await rm(path, { force: true });
164
+ if (messages.length >= limit) break;
165
+ }
166
+ return messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id)).slice(0, limit);
167
+ }
168
+
169
+ async function readClaims(): Promise<ClaimRecord[]> {
170
+ const claims: ClaimRecord[] = [];
171
+ for (const name of await listFiles(claimsDir)) {
172
+ const claim = readTyped(join(claimsDir, name), isClaim);
173
+ if (claim) claims.push(claim);
174
+ }
175
+ return claims.sort((a, b) => a.path.localeCompare(b.path));
176
+ }
177
+
178
+ async function claimOne(
179
+ path: string,
180
+ owner: string,
181
+ ownerLabel: string,
182
+ at: number,
183
+ ): Promise<{ claim: ClaimRecord; conflict?: ClaimRecord }> {
184
+ await mkdir(claimsDir, { recursive: true, mode: DIR_MODE });
185
+ const file = claimFile(path);
186
+ const claim: ClaimRecord = { path, owner, ownerLabel, at };
187
+ // Publish a written temp file instead of creating the claim and filling it
188
+ // afterwards: the link is the exclusive create, so no reader can observe a
189
+ // half-written claim and mistake it for a crashed owner's.
190
+ const temp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
191
+ await writeFile(temp, `${JSON.stringify(claim, null, 2)}\n`, { encoding: "utf-8", mode: FILE_MODE });
192
+ try {
193
+ try {
194
+ await link(temp, file);
195
+ return { claim };
196
+ } catch (error) {
197
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
198
+ }
199
+ const existing = readTyped(file, isClaim);
200
+ if (existing) {
201
+ return existing.owner === owner ? { claim: existing } : { claim, conflict: existing };
202
+ }
203
+ // A claim file that does not parse is debris from before claims were
204
+ // published whole; clear it once so a stale path cannot block forever.
205
+ await rm(file, { force: true });
206
+ await link(temp, file);
207
+ return { claim };
208
+ } finally {
209
+ await rm(temp, { force: true });
210
+ }
211
+ }
212
+
213
+ async function releaseOne(path: string, owner: string): Promise<boolean> {
214
+ const file = claimFile(path);
215
+ const existing = readTyped(file, isClaim);
216
+ if (!existing || existing.owner !== owner) return false;
217
+ await rm(file, { force: true });
218
+ return true;
219
+ }
220
+
221
+ /**
222
+ * Claim every path for one owner, or nothing.
223
+ *
224
+ * The pre-check reads the claims once and reports every blocking owner, so a
225
+ * batch that cannot be satisfied touches no file. A create that loses a race
226
+ * after the pre-check releases the paths this batch already took.
227
+ */
228
+ async function claimAll(
229
+ paths: readonly string[],
230
+ owner: string,
231
+ ownerLabel: string,
232
+ at: number,
233
+ ): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }> {
234
+ const existing = await readClaims();
235
+ const conflicts: ClaimConflict[] = [];
236
+ for (const path of paths) {
237
+ for (const held of existing) {
238
+ if (held.owner !== owner && claimsConflict(path, held.path)) {
239
+ conflicts.push({ path, owner: held.owner, ownerLabel: held.ownerLabel });
240
+ }
241
+ }
242
+ }
243
+ if (conflicts.length > 0) return { claimed: [], conflicts };
244
+ const claimed: string[] = [];
245
+ for (const path of paths) {
246
+ const result = await claimOne(path, owner, ownerLabel, at);
247
+ if (result.conflict) {
248
+ for (const done of claimed) await releaseOne(done, owner);
249
+ const { owner: holder, ownerLabel: holderLabel } = result.conflict;
250
+ return { claimed: [], conflicts: [{ path, owner: holder, ownerLabel: holderLabel }] };
251
+ }
252
+ claimed.push(path);
253
+ }
254
+ return { claimed, conflicts: [] };
255
+ }
256
+
257
+ return {
258
+ dir,
259
+ async ensure() {
260
+ await Promise.all([
261
+ mkdir(sessionsDir, { recursive: true, mode: DIR_MODE }),
262
+ mkdir(inboxRoot, { recursive: true, mode: DIR_MODE }),
263
+ mkdir(claimsDir, { recursive: true, mode: DIR_MODE }),
264
+ ]);
265
+ },
266
+
267
+ async writeSession(record) {
268
+ await writeJson(sessionFile(record.id), record);
269
+ },
270
+
271
+ sessions: readSessions,
272
+
273
+ async removeSession(id) {
274
+ await rm(sessionFile(id), { force: true });
275
+ await dropIfEmpty(inboxDir(id));
276
+ },
277
+
278
+ async writeMessage(target, message) {
279
+ const directory = inboxDir(target);
280
+ await mkdir(directory, { recursive: true, mode: DIR_MODE });
281
+ await writeJson(join(directory, `${message.id}.json`), message);
282
+ },
283
+
284
+ peek: (id, limit) => readMessages(id, limit, false),
285
+
286
+ inbox: (id, limit) => readMessages(id, limit, true),
287
+
288
+ async unread(id) {
289
+ return (await listFiles(inboxDir(id))).length;
290
+ },
291
+
292
+ claims: readClaims,
293
+
294
+ claimAll,
295
+
296
+ release: releaseOne,
297
+
298
+ async releaseAll(owner) {
299
+ const released: string[] = [];
300
+ for (const claim of await readClaims()) {
301
+ if (claim.owner !== owner) continue;
302
+ await rm(claimFile(claim.path), { force: true });
303
+ released.push(claim.path);
304
+ }
305
+ return released;
306
+ },
307
+
308
+ async prune({ now: at, activeIds, sessionTtlMs, messageTtlMs }) {
309
+ for (const session of await readSessions()) {
310
+ if (at - session.lastSeen > sessionTtlMs) {
311
+ await rm(sessionFile(session.id), { force: true });
312
+ await dropIfEmpty(inboxDir(session.id));
313
+ activeIds = new Set([...activeIds].filter((id) => id !== session.id));
314
+ }
315
+ }
316
+ for (const claim of await readClaims()) {
317
+ if (!activeIds.has(claim.owner)) await rm(claimFile(claim.path), { force: true });
318
+ }
319
+ for (const name of await readdir(inboxRoot).catch(() => [] as string[])) {
320
+ const directory = join(inboxRoot, name);
321
+ for (const file of await listFiles(directory)) {
322
+ const message = readTyped(join(directory, file), isMessage);
323
+ if (!message || at - message.at > messageTtlMs) await rm(join(directory, file), { force: true });
324
+ }
325
+ await dropIfEmpty(directory);
326
+ }
327
+ },
328
+ };
329
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Reading a split out of the plan store, for a session that did not write it.
3
+ *
4
+ * The bus and plan mode never import each other, so the plan file is the
5
+ * contract: this module finds the newest plan that carries a `## Team` section,
6
+ * or the one a `join` named, and reads it through the shared parser. Everything
7
+ * here is a refusal with a reason — a hand-edited or stale section must never
8
+ * half-read, because a unit read wrong is a session claiming the wrong paths.
9
+ */
10
+
11
+ import { readFileSync, readdirSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { shortenPath } from "../../lib/format.ts";
14
+ import { type PlanUnit, readTeam } from "../../lib/plans/team.ts";
15
+
16
+ /** One unit, with the team it belongs to. */
17
+ export interface JoinedUnit {
18
+ team: string;
19
+ name: string;
20
+ paths: string[];
21
+ }
22
+
23
+ /** The team a `join` or a status line is about. */
24
+ export interface TeamRef {
25
+ team: string;
26
+ units: PlanUnit[];
27
+ }
28
+
29
+ /**
30
+ * Resolve a `team`/`unit` pair to the paths a join claims.
31
+ *
32
+ * `team` is the plan file's stem, which is why two plans in one repository never
33
+ * share a team: the stamp in the name is what keeps them apart.
34
+ */
35
+ export function resolveUnit(plansRoot: string, input: { team?: string; unit?: string }, verb = "join"): JoinedUnit {
36
+ const team = input.team?.trim();
37
+ const wanted = input.unit?.trim();
38
+ if (!team) throw new Error(`${verb} needs a team: pass the plan file name without ".md".`);
39
+ if (!wanted) throw new Error(`${verb} needs a unit inside "${team}".`);
40
+ const file = join(plansRoot, `${team.replace(/[\\/]/g, "")}.md`);
41
+ let content: string;
42
+ try {
43
+ content = readFileSync(file, "utf-8");
44
+ } catch {
45
+ throw new Error(`No split plan named "${team}" in this repository's plan store (${shortenPath(plansRoot)}).`);
46
+ }
47
+ const parsed = readTeam(content);
48
+ if (parsed === undefined) {
49
+ throw new Error(`"${team}" has no team section; a split is written when a plan is approved with plan.split on.`);
50
+ }
51
+ if ("error" in parsed) throw new Error(`${team}: ${parsed.error}`);
52
+ const unit = parsed.units.find((entry) => entry.name === wanted);
53
+ if (!unit) {
54
+ const known = parsed.units.map((entry) => entry.name).join(", ");
55
+ throw new Error(`"${team}" has no unit "${wanted}"; it has ${known}.`);
56
+ }
57
+ return { team, name: unit.name, paths: [...unit.paths] };
58
+ }
59
+
60
+ /** How many of the newest plans a status call reads before giving up on finding a split. */
61
+ const MAX_TEAM_SCAN = 20;
62
+
63
+ /** The newest plan in the store that carries a team, or undefined when none does. */
64
+ export function newestTeam(plansRoot: string): TeamRef | undefined {
65
+ let names: string[];
66
+ try {
67
+ names = readdirSync(plansRoot).filter((name) => name.toLowerCase().endsWith(".md") && !name.startsWith("."));
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ const stamped = names
72
+ .map((name) => {
73
+ try {
74
+ return { name, at: statSync(join(plansRoot, name)).mtimeMs };
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ })
79
+ .filter((entry): entry is { name: string; at: number } => entry !== undefined)
80
+ .sort((a, b) => b.at - a.at)
81
+ .slice(0, MAX_TEAM_SCAN);
82
+ for (const entry of stamped) {
83
+ try {
84
+ const team = readTeam(readFileSync(join(plansRoot, entry.name), "utf-8"));
85
+ if (team !== undefined && !("error" in team)) {
86
+ return { team: entry.name.replace(/\.md$/i, ""), units: team.units };
87
+ }
88
+ } catch {
89
+ // An unreadable plan is simply not the newest team.
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /** The label holding any of `unit`'s paths, from the bus's claims. */
96
+ export function unitHolder(
97
+ unit: PlanUnit,
98
+ claims: readonly { path: string; ownerLabel: string }[],
99
+ conflicts: (a: string, b: string) => boolean,
100
+ ): string | undefined {
101
+ return claims.find((claim) => unit.paths.some((path) => conflicts(claim.path, path)))?.ownerLabel;
102
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The `session` tool.
3
+ *
4
+ * One action-shaped tool keeps the prompt surface small: status, send, inbox,
5
+ * claim, release, join, leave, watch, name. The runtime owns file I/O and
6
+ * lifecycle; this module only validates the action, calls the runtime, and hands
7
+ * the result to the shared presentation module — one collapsed line, or the
8
+ * board's rows on expand.
9
+ */
10
+
11
+ import { StringEnum } from "@earendil-works/pi-ai";
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { Type, type Static } from "typebox";
14
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
+ import { errorResult, textResult } from "../../lib/tool-result.ts";
16
+ import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
17
+ import {
18
+ formatClaim,
19
+ formatInbox,
20
+ formatJoin,
21
+ formatName,
22
+ formatRelease,
23
+ formatSend,
24
+ formatStatus,
25
+ formatWatch,
26
+ } from "./format.ts";
27
+ import { normalizeAction, TOOL_NAME } from "./protocol.ts";
28
+ import type { SessionBus } from "./runtime.ts";
29
+ import { collapsedLine, resultSections, SessionResult } from "./result.ts";
30
+ import type { SessionDetails } from "./types.ts";
31
+
32
+ export { TOOL_NAME };
33
+
34
+ const SessionParams = Type.Object({
35
+ action: StringEnum(["status", "send", "inbox", "claim", "release", "join", "leave", "watch", "name"] as const, {
36
+ description: "status, send, inbox, claim, release, join, leave, watch, or name.",
37
+ }),
38
+ to: Type.Optional(Type.String({ description: "Target session label/id, or * for an event broadcast." })),
39
+ text: Type.Optional(Type.String({ description: "Message text, at most 4000 characters." })),
40
+ kind: Type.Optional(
41
+ StringEnum(["message", "event", "ask", "handoff"] as const, {
42
+ description: "Message kind; defaults to message.",
43
+ }),
44
+ ),
45
+ paths: Type.Optional(
46
+ Type.Array(Type.String(), { description: "Repository-relative file or directory paths to claim or release." }),
47
+ ),
48
+ limit: Type.Optional(
49
+ Type.Integer({ minimum: 1, maximum: 50, description: "Inbox messages to consume (default 20)." }),
50
+ ),
51
+ wake: Type.Optional(
52
+ Type.Boolean({
53
+ description:
54
+ "With watch: set the mode — true raises one turn per burst, false waits for the next turn, omitted keeps it.",
55
+ }),
56
+ ),
57
+ stop: Type.Optional(Type.Boolean({ description: "With watch: turn this session's inbox watch off." })),
58
+ team: Type.Optional(
59
+ Type.String({
60
+ description: "With join/leave: the split's name — the plan file name without `.md`.",
61
+ }),
62
+ ),
63
+ unit: Type.Optional(Type.String({ description: "With join/leave: the unit to claim or release, e.g. `core`." })),
64
+ label: Type.Optional(
65
+ Type.String({ description: "With name: what other sessions call this one, e.g. `billing-lead`." }),
66
+ ),
67
+ });
68
+
69
+ type SessionArgs = Static<typeof SessionParams>;
70
+
71
+ /**
72
+ * The call line's subject: the verb, and what it acts on. `end` clipping keeps
73
+ * the verb readable, because the paths that follow are the detail, not the
74
+ * identity of the call.
75
+ */
76
+ function callSubject(action: string, args: Record<string, unknown>): string {
77
+ if (action === "name") {
78
+ const label = typeof args.label === "string" ? args.label.trim() : "";
79
+ return label ? `name ${label}` : "name";
80
+ }
81
+ if (action === "send") {
82
+ const to = typeof args.to === "string" ? args.to : "";
83
+ return to ? `send ${to}` : "send";
84
+ }
85
+ if (action === "join" || action === "leave") {
86
+ const unit = typeof args.unit === "string" ? args.unit : "";
87
+ const team = typeof args.team === "string" ? args.team : "";
88
+ return [action, unit, team && !unit ? team : ""].filter(Boolean).join(" ");
89
+ }
90
+ if (action !== "claim" && action !== "release") return action;
91
+ const paths = Array.isArray(args.paths) ? args.paths.filter((path): path is string => typeof path === "string") : [];
92
+ const [first, ...rest] = paths;
93
+ if (first === undefined) return action;
94
+ return `${action} ${first}${rest.length > 0 ? ` +${rest.length}` : ""}`;
95
+ }
96
+
97
+ export function registerTools(pi: ExtensionAPI, bus: SessionBus): void {
98
+ pi.registerTool({
99
+ name: TOOL_NAME,
100
+ label: "Session bus",
101
+ description:
102
+ "Coordinate parallel Pi sessions on this repository. Actions: `status` (sessions, unread, claims, joined units), " +
103
+ "`send` (to a label/id, or `*` for an event), `inbox` (consume messages), `claim`/`release` (paths), " +
104
+ "`join`/`leave` (a unit of an approved split, by `team` and `unit`), " +
105
+ "`watch` (report new mail at your next turn, or raise one with `wake`; `stop` turns it off). " +
106
+ "Delivery is pull-based unless this session arms `watch`.",
107
+ promptSnippet: "Coordinate parallel sessions: status, send, inbox, claim, release, join, leave, watch, name.",
108
+ promptGuidelines: [
109
+ "Check `session inbox` when starting or joining a coordinated turn.",
110
+ "Claim the paths you will edit before changing them; release them when done.",
111
+ "When a plan was approved with a split, `join` its unit once at the start of the work and `leave` it when merged.",
112
+ 'Send `kind: "event"` for contract, build, or test changes another session needs.',
113
+ "Never put credentials or secrets in a session-bus message.",
114
+ ],
115
+ parameters: SessionParams,
116
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
117
+ executionMode: "sequential",
118
+
119
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
120
+ const args = params as SessionArgs;
121
+ const action = normalizeAction(args.action);
122
+ if (!action) return errorResult("status", `unknown action "${String(args.action)}".`);
123
+ try {
124
+ await bus.start(ctx);
125
+ switch (action) {
126
+ case "status": {
127
+ const status = await bus.status();
128
+ return textResult(formatStatus(status), { action, status });
129
+ }
130
+ case "send": {
131
+ const result = await bus.send({
132
+ to: typeof args.to === "string" ? args.to : "",
133
+ text: typeof args.text === "string" ? args.text : "",
134
+ kind: args.kind,
135
+ paths: args.paths,
136
+ });
137
+ return textResult(formatSend(result.delivered, result.messageId), {
138
+ action,
139
+ delivered: result.delivered,
140
+ messageId: result.messageId,
141
+ });
142
+ }
143
+ case "inbox": {
144
+ const messages = await bus.inbox(args.limit);
145
+ return textResult(formatInbox(messages), { action, messages });
146
+ }
147
+ case "claim": {
148
+ const result = await bus.claim(args.paths);
149
+ return textResult(formatClaim(result.claimed, result.conflicts), {
150
+ action,
151
+ claimed: result.claimed,
152
+ conflicts: result.conflicts,
153
+ });
154
+ }
155
+ case "release": {
156
+ const result = await bus.release(args.paths);
157
+ return textResult(formatRelease(result.released, result.notOwned), {
158
+ action,
159
+ released: result.released,
160
+ notOwned: result.notOwned,
161
+ });
162
+ }
163
+ case "join": {
164
+ const result = await bus.join({ team: args.team, unit: args.unit });
165
+ return textResult(formatJoin(result.team, result.unit, result.claimed, result.conflicts), {
166
+ action,
167
+ team: result.team,
168
+ unit: result.unit,
169
+ claimed: result.claimed,
170
+ conflicts: result.conflicts,
171
+ });
172
+ }
173
+ case "leave": {
174
+ const result = await bus.leave({ team: args.team, unit: args.unit });
175
+ return textResult(formatRelease(result.released, result.notOwned), {
176
+ action,
177
+ released: result.released,
178
+ notOwned: result.notOwned,
179
+ ...(args.team === undefined ? {} : { team: args.team }),
180
+ ...(args.unit === undefined ? {} : { unit: args.unit }),
181
+ });
182
+ }
183
+ case "watch": {
184
+ const watching = await bus.watch({ wake: args.wake, stop: args.stop });
185
+ return textResult(formatWatch(watching), { action, watching });
186
+ }
187
+ case "name": {
188
+ const label = typeof args.label === "string" ? args.label.trim() : "";
189
+ if (!label) return errorResult(action, 'name requires a label, e.g. "billing-lead".');
190
+ const named = await bus.rename(label);
191
+ // The bus's label and Pi's session name are one identity: the footer,
192
+ // `/resume`, and the record a peer addresses have to agree.
193
+ pi.setSessionName(named);
194
+ return textResult(formatName(named), { action, label: named });
195
+ }
196
+ }
197
+ } catch (error) {
198
+ return errorResult(action, error instanceof Error ? error.message : String(error));
199
+ }
200
+ },
201
+
202
+ renderCall(args, theme, context) {
203
+ const action = typeof args.action === "string" ? args.action : "session";
204
+ return callLine(theme, context.lastComponent, TOOL_NAME, callSubject(action, args as Record<string, unknown>));
205
+ },
206
+
207
+ renderResult(result, options, theme, context) {
208
+ const text = reuseText(context?.lastComponent);
209
+ const details = toolDetails<SessionDetails>(result.details);
210
+ const outcome = detailsOrLine(theme, result, options.expanded, details);
211
+ if ("line" in outcome) {
212
+ text.setText(outcome.line);
213
+ return text;
214
+ }
215
+ const value = outcome.details;
216
+ const sections = options.expanded ? resultSections(value) : undefined;
217
+ if (!sections) {
218
+ text.setText(collapsedLine(theme, value));
219
+ return text;
220
+ }
221
+ const view =
222
+ context?.lastComponent instanceof SessionResult ? context.lastComponent : new SessionResult(sections, theme);
223
+ view.setInput(sections, theme);
224
+ return view;
225
+ },
226
+ });
227
+ }