@gavin-hu/my-pi-agent 0.17.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 (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. package/extensions/wechat/qrcode.ts +0 -46
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The `/sessions` board: a docked list over the bus, with three scopes.
3
+ *
4
+ * `Tab` cycles Sessions → Inbox → Claims. Sessions and claims come from the bus
5
+ * snapshot, mail from a read-only peek, so the board announces nothing and
6
+ * consumes nothing. The rows, summaries, and focus blocks are in `rows.ts`; this
7
+ * module owns the screen, the scope it shows, and the two verbs that change this
8
+ * session's own state.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { matchesKey, Key } from "@earendil-works/pi-tui";
13
+ import { messageOf } from "../../lib/errors.ts";
14
+ import { formatDuration, sanitizeLine } from "../../lib/format.ts";
15
+ import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
16
+ import { type DetailContents, DockListScreen } from "../../lib/tui/dock-screen.ts";
17
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
18
+ import { keyValueRow } from "../../lib/tui/rows.ts";
19
+ import { paint } from "../../lib/tui/tokens.ts";
20
+ import { shortId } from "./format.ts";
21
+ import {
22
+ type SessionRow,
23
+ claimRowLine,
24
+ count,
25
+ messageRowLine,
26
+ sessionDetailBlock,
27
+ sessionRowLine,
28
+ sessionRows,
29
+ summaryCells,
30
+ } from "./rows.ts";
31
+ import type { BoardSnapshot, BusMessage, ClaimRecord } from "./types.ts";
32
+
33
+ /** The scopes `Tab` cycles through. */
34
+ export type BoardScope = "sessions" | "inbox" | "claims";
35
+
36
+ /** One row of the board, whichever scope is showing. */
37
+ type BoardRow =
38
+ | { kind: "session"; row: SessionRow }
39
+ | { kind: "message"; message: BusMessage }
40
+ | { kind: "claim"; claim: ClaimRecord };
41
+
42
+ const SCOPE_ORDER: readonly BoardScope[] = ["sessions", "inbox", "claims"];
43
+
44
+ const SCOPE_TITLE: Readonly<Record<BoardScope, string>> = {
45
+ sessions: "Sessions",
46
+ inbox: "Inbox",
47
+ claims: "Claims",
48
+ };
49
+
50
+ const SCOPE_EMPTY: Readonly<Record<BoardScope, string>> = {
51
+ sessions: "No sessions on this bus.",
52
+ inbox: "No unread mail.",
53
+ claims: "No claims.",
54
+ };
55
+
56
+ /** A relative age for a message or a claim, e.g. `2.0s ago`, `5m00s ago`. */
57
+ function ago(at: number, now: number): string {
58
+ return `${formatDuration(Math.max(0, now - at))} ago`;
59
+ }
60
+
61
+ /** A message body as prose: one paragraph per source line, control characters gone. */
62
+ function messageProse(message: BusMessage): string {
63
+ return message.text
64
+ .split("\n")
65
+ .map((line) => sanitizeLine(line))
66
+ .join("\n");
67
+ }
68
+
69
+ /** The paths a message mentions, or an empty string. */
70
+ function messagePaths(message: BusMessage): string {
71
+ return message.paths?.length ? message.paths.map((path) => sanitizeLine(path)).join(", ") : "";
72
+ }
73
+
74
+ /** The pane's width-dependent facts: identity, above the claim body. */
75
+ function paneFacts(theme: Theme, row: SessionRow, now: number, width: number): string[] {
76
+ const labelWidth = 10;
77
+ return [
78
+ keyValueRow(theme, width, { label: "mode", labelWidth, value: sanitizeLine(row.session.mode) }),
79
+ keyValueRow(theme, width, { label: "id", labelWidth, value: shortId(row.session.id) }),
80
+ keyValueRow(theme, width, {
81
+ label: "last seen",
82
+ labelWidth,
83
+ value: ago(row.session.lastSeen, now),
84
+ }),
85
+ ];
86
+ }
87
+
88
+ /** The pane body: every claim this session holds, in path order. */
89
+ function paneLines(row: SessionRow, now: number): string[] {
90
+ return row.claims.map((claim) => `${sanitizeLine(claim.path)}${SEPARATORS.item}held ${ago(claim.at, now)}`);
91
+ }
92
+
93
+ /** A cheap signature of what the board draws, so a poll repaints only on a change. */
94
+ function boardKey(board: BoardSnapshot | undefined): string {
95
+ if (!board) return "";
96
+ return [
97
+ ...board.status.sessions.map(
98
+ (session) =>
99
+ `${session.id}:${session.label}:${session.unread}:${session.lastSeen}:${session.mode}:${session.cwd}`,
100
+ ),
101
+ ...board.status.claims.map((claim) => `${claim.path}:${claim.owner}:${claim.ownerLabel}:${claim.at}`),
102
+ board.status.watching ? `${board.status.watching.armed}:${board.status.watching.wake}` : "",
103
+ ...board.messages.map((message) => `${message.id}:${message.at}`),
104
+ ].join("|");
105
+ }
106
+
107
+ export interface SessionListCallbacks {
108
+ /** Arm (or disarm) this session's inbox watch, returning the line to show. */
109
+ toggleWatch(armed: boolean): Promise<string>;
110
+ /** Release one of this session's claims, returning the line to show. */
111
+ releaseClaim(path: string): Promise<string>;
112
+ }
113
+
114
+ interface SessionListOptions {
115
+ /** The latest board, or undefined before the first read. */
116
+ latest(): BoardSnapshot | undefined;
117
+ theme: Theme;
118
+ callbacks: SessionListCallbacks;
119
+ onClose(): void;
120
+ requestRender(): void;
121
+ now?: () => number;
122
+ viewportRowsSource?: ViewportRowsSource;
123
+ }
124
+
125
+ /**
126
+ * The `/sessions` board.
127
+ *
128
+ * The rows are derived from a board the command's poll supplies, so the screen
129
+ * never reads the bus itself; `liveDetail` keeps an open pane in step with that
130
+ * board, so a peer that expires or a message that is consumed closes its own pane.
131
+ * The focus is anchored by a scope-prefixed id, so switching scopes lands on the
132
+ * first row of the new list instead of a row that is no longer there.
133
+ */
134
+ export class SessionListComponent extends DockListScreen<BoardRow> {
135
+ private board: BoardSnapshot | undefined;
136
+ private scope: BoardScope = "sessions";
137
+ private readonly callbacks: SessionListCallbacks;
138
+ private readonly now: () => number;
139
+
140
+ constructor(options: SessionListOptions) {
141
+ super({
142
+ theme: options.theme,
143
+ title: "Sessions",
144
+ detailTitle: "Details",
145
+ emptyList: "No sessions on this bus.",
146
+ emptyDetail: "Nothing to show.",
147
+ requestRender: options.requestRender,
148
+ onClose: options.onClose,
149
+ viewportRowsSource: options.viewportRowsSource,
150
+ });
151
+ this.callbacks = options.callbacks;
152
+ this.now = options.now ?? Date.now;
153
+ this.board = options.latest();
154
+ }
155
+
156
+ /** Replace the board; a poll that changed nothing does not repaint. */
157
+ setBoard(board: BoardSnapshot | undefined): void {
158
+ const before = boardKey(this.board);
159
+ this.board = board;
160
+ if (boardKey(board) !== before) this.options.requestRender();
161
+ }
162
+
163
+ /** The scope currently listed, for a test or a caller that wants to label it. */
164
+ get currentScope(): BoardScope {
165
+ return this.scope;
166
+ }
167
+
168
+ protected headerLabel(): string {
169
+ return SCOPE_TITLE[this.scope];
170
+ }
171
+
172
+ protected emptyLabel(): string {
173
+ return SCOPE_EMPTY[this.scope];
174
+ }
175
+
176
+ protected rows(): readonly BoardRow[] {
177
+ switch (this.scope) {
178
+ case "sessions":
179
+ return sessionRows(this.board?.status).map((row) => ({ kind: "session", row }));
180
+ case "inbox":
181
+ return (this.board?.messages ?? []).map((message) => ({ kind: "message", message }));
182
+ case "claims":
183
+ return (this.board?.status.claims ?? []).map((claim) => ({ kind: "claim", claim }));
184
+ }
185
+ }
186
+
187
+ protected idOf(row: BoardRow): string {
188
+ switch (row.kind) {
189
+ case "session":
190
+ return `s:${row.row.session.id}`;
191
+ case "message":
192
+ return `m:${row.message.id}`;
193
+ case "claim":
194
+ return `c:${row.claim.path}`;
195
+ }
196
+ }
197
+
198
+ protected rowLine(row: BoardRow, width: number, selected: boolean): string {
199
+ switch (row.kind) {
200
+ case "session":
201
+ return sessionRowLine(row.row, this.theme, width, { selected });
202
+ case "message":
203
+ return messageRowLine(row.message, this.theme, width, { selected });
204
+ case "claim":
205
+ return claimRowLine(row.claim, this.theme, width, {
206
+ selected,
207
+ self: row.claim.owner === this.me(),
208
+ });
209
+ }
210
+ }
211
+
212
+ protected detailBlock(row: BoardRow): string[] {
213
+ switch (row.kind) {
214
+ case "session":
215
+ return sessionDetailBlock(row.row, this.now());
216
+ case "message": {
217
+ const lines = [`received: ${ago(row.message.at, this.now())}`];
218
+ const paths = messagePaths(row.message);
219
+ if (paths) lines.push(`paths: ${paths}`);
220
+ return lines;
221
+ }
222
+ case "claim":
223
+ return [`held ${ago(row.claim.at, this.now())}`];
224
+ }
225
+ }
226
+
227
+ protected summaryCells(rows: readonly BoardRow[]): string[] {
228
+ const watching = this.board?.status.watching;
229
+ switch (this.scope) {
230
+ case "sessions": {
231
+ const sessions = rows.flatMap((row) => (row.kind === "session" ? [row.row] : []));
232
+ return summaryCells(this.theme, sessions, watching);
233
+ }
234
+ case "inbox": {
235
+ const cells = [paint(this.theme, "meta", count(rows.length, "unread message"))];
236
+ if (watching) cells.push(this.watchCell(watching));
237
+ return cells;
238
+ }
239
+ case "claims": {
240
+ const own = rows.flatMap((row) => (row.kind === "claim" && row.claim.owner === this.me() ? [row] : []));
241
+ const cells = [paint(this.theme, "meta", count(rows.length, "claim"))];
242
+ if (own.length > 0) cells.push(paint(this.theme, "label", `${own.length} yours`));
243
+ return cells;
244
+ }
245
+ }
246
+ }
247
+
248
+ protected listHints(): ScreenHint[] {
249
+ const tab = (scope: BoardScope): ScreenHint => ({ text: `Tab ${SCOPE_TITLE[scope].toLowerCase()}` });
250
+ const enter = (verb: string): ScreenHint => ({ text: `Enter ${verb}`, color: "accent" });
251
+ const hints: ScreenHint[] = ["Esc", tab(this.nextScope())];
252
+ switch (this.scope) {
253
+ case "sessions":
254
+ hints.push(enter("open"), this.watchHint(), "↑/↓", "PgUp/PgDn");
255
+ break;
256
+ case "inbox":
257
+ hints.push(enter("read"), this.watchHint(), "↑/↓", "PgUp/PgDn");
258
+ break;
259
+ case "claims":
260
+ hints.push(enter("open"), { text: "x release", color: "warning" }, "↑/↓", "PgUp/PgDn");
261
+ break;
262
+ }
263
+ return hints;
264
+ }
265
+
266
+ protected detailHints(row: BoardRow | undefined): ScreenHint[] {
267
+ if (row?.kind === "claim" && row.claim.owner === this.me()) {
268
+ return ["Esc back", { text: "x release", color: "warning" }, "↑/↓ scroll", "g/G ends"];
269
+ }
270
+ return ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"];
271
+ }
272
+
273
+ protected detailFor(row: BoardRow): DetailContents {
274
+ switch (row.kind) {
275
+ case "session":
276
+ return {
277
+ title: `${sanitizeLine(row.row.session.label)}${row.row.self ? " · you" : ""}`,
278
+ subtitle: sanitizeLine(row.row.session.cwd),
279
+ lines: paneLines(row.row, this.now()),
280
+ tail: false,
281
+ facts: (width) => paneFacts(this.theme, row.row, this.now(), width),
282
+ };
283
+ case "message": {
284
+ const paths = messagePaths(row.message);
285
+ return {
286
+ title: `${sanitizeLine(row.message.fromLabel)}${SEPARATORS.item}${sanitizeLine(row.message.kind)}`,
287
+ subtitle: `${ago(row.message.at, this.now())}${paths ? `${SEPARATORS.item}paths: ${paths}` : ""}`,
288
+ text: messageProse(row.message),
289
+ tail: false,
290
+ };
291
+ }
292
+ case "claim": {
293
+ // A claim's pane is the session that holds it: identity plus everything it holds.
294
+ const owner = this.ownerRow(row.claim.owner);
295
+ return {
296
+ title: sanitizeLine(row.claim.ownerLabel),
297
+ subtitle: `holds ${sanitizeLine(row.claim.path)}${SEPARATORS.item}held ${ago(row.claim.at, this.now())}`,
298
+ lines: owner ? paneLines(owner, this.now()) : [],
299
+ tail: false,
300
+ ...(owner ? { facts: (width: number) => paneFacts(this.theme, owner, this.now(), width) } : {}),
301
+ };
302
+ }
303
+ }
304
+ }
305
+
306
+ protected detailById(id: string): { lines: string[] } | undefined {
307
+ const row = this.rows().find((candidate) => this.idOf(candidate) === id);
308
+ if (!row) return undefined;
309
+ switch (row.kind) {
310
+ case "session":
311
+ return { lines: paneLines(row.row, this.now()) };
312
+ case "claim": {
313
+ const owner = this.ownerRow(row.claim.owner);
314
+ return { lines: owner ? paneLines(owner, this.now()) : [] };
315
+ }
316
+ case "message":
317
+ // The pane's body is prose, which the base owns; its presence is all this answers.
318
+ return { lines: [] };
319
+ }
320
+ }
321
+
322
+ protected liveDetail(): boolean {
323
+ return true;
324
+ }
325
+
326
+ protected handleVerb(key: string, focused: BoardRow | undefined): void {
327
+ if (matchesKey(key, Key.tab)) {
328
+ this.cycleScope();
329
+ return;
330
+ }
331
+ if (key === "w") {
332
+ this.toggleWatch();
333
+ return;
334
+ }
335
+ if (key === "x" && focused?.kind === "claim") this.release(focused.claim);
336
+ }
337
+
338
+ protected handleDetailVerb(key: string, row: BoardRow): boolean {
339
+ if (key !== "x" || row.kind !== "claim") return false;
340
+ this.release(row.claim);
341
+ return true;
342
+ }
343
+
344
+ private me(): string {
345
+ return this.board?.status.me.id ?? "";
346
+ }
347
+
348
+ /** The row for the session with `id`, when it is still on the board. */
349
+ private ownerRow(id: string): SessionRow | undefined {
350
+ return sessionRows(this.board?.status).find((row) => row.session.id === id);
351
+ }
352
+
353
+ private nextScope(): BoardScope {
354
+ const index = SCOPE_ORDER.indexOf(this.scope);
355
+ return SCOPE_ORDER[(index + 1) % SCOPE_ORDER.length] ?? "sessions";
356
+ }
357
+
358
+ private cycleScope(): void {
359
+ this.scope = this.nextScope();
360
+ this.options.requestRender();
361
+ }
362
+
363
+ private watchCell(watching: { armed: boolean; wake: boolean }): string {
364
+ return paint(this.theme, "ok", watching.wake ? "watch wake" : "watch on");
365
+ }
366
+
367
+ private watchHint(): ScreenHint {
368
+ return this.board?.status.watching
369
+ ? { text: "w watch off", color: "warning" }
370
+ : { text: "w watch on", color: "accent" };
371
+ }
372
+
373
+ private toggleWatch(): void {
374
+ const armed = this.board?.status.watching !== undefined;
375
+ void this.callbacks
376
+ .toggleWatch(armed)
377
+ .then((line) => this.notify(line))
378
+ .catch((error) => this.notify(messageOf(error)));
379
+ }
380
+
381
+ private release(claim: ClaimRecord): void {
382
+ const path = sanitizeLine(claim.path);
383
+ if (claim.owner !== this.me()) {
384
+ this.notify(`${path} is ${sanitizeLine(claim.ownerLabel)}'s claim; not released.`);
385
+ return;
386
+ }
387
+ this.confirm(`Release ${path}? y/N`, () => {
388
+ void this.callbacks
389
+ .releaseClaim(claim.path)
390
+ .then((line) => this.notify(line))
391
+ .catch((error) => this.notify(messageOf(error)));
392
+ });
393
+ }
394
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shared shapes for the session bus.
3
+ *
4
+ * These describe the files and the tool result only; the file I/O lives in
5
+ * `store.ts` and the lifecycle in `runtime.ts`.
6
+ */
7
+
8
+ import type { MessageKind, SessionMode } from "./protocol.ts";
9
+
10
+ /** One registered Pi session. */
11
+ export interface SessionRecord {
12
+ id: string;
13
+ label: string;
14
+ pid: number;
15
+ /** Working directory of the session; the worktree root when there is one. */
16
+ cwd: string;
17
+ /** Repository root the session belongs to, for relative claims. */
18
+ repoRoot: string;
19
+ /** The mode the session was started in. */
20
+ mode: SessionMode;
21
+ startedAt: number;
22
+ lastSeen: number;
23
+ }
24
+
25
+ /** One message waiting in a recipient's inbox. */
26
+ export interface BusMessage {
27
+ id: string;
28
+ from: string;
29
+ fromLabel: string;
30
+ /** Recipient session id. */
31
+ to: string;
32
+ toLabel: string;
33
+ kind: MessageKind;
34
+ text: string;
35
+ paths?: string[];
36
+ at: number;
37
+ }
38
+
39
+ /** One repository-relative claim. */
40
+ export interface ClaimRecord {
41
+ path: string;
42
+ owner: string;
43
+ ownerLabel: string;
44
+ at: number;
45
+ }
46
+
47
+ /** What `session status` returns. */
48
+ export interface SessionStatus {
49
+ me: { id: string; label: string };
50
+ sessions: Array<SessionRecord & { unread: number }>;
51
+ claims: ClaimRecord[];
52
+ /** The newest split in this repository, and who holds each unit, on an explicit status call. */
53
+ units?: {
54
+ team: string;
55
+ joined: number;
56
+ total: number;
57
+ rows: Array<{ unit: string; lead?: boolean; holder?: string }>;
58
+ };
59
+ /** Present only when this session armed its inbox watch. */
60
+ watching?: { armed: boolean; wake: boolean };
61
+ }
62
+
63
+ /** A conflict returned by `session claim`. */
64
+ export interface ClaimConflict {
65
+ path: string;
66
+ owner: string;
67
+ ownerLabel: string;
68
+ }
69
+
70
+ /**
71
+ * What the board reads in one poll: the bus as it stands, and this session's
72
+ * unread mail. Peeking never consumes, so the notice and `inbox` are unaffected.
73
+ */
74
+ export interface BoardSnapshot {
75
+ status: SessionStatus;
76
+ messages: BusMessage[];
77
+ }
78
+
79
+ /** Structured result carried in the `session` tool's details. */
80
+ export interface SessionDetails {
81
+ action: "status" | "send" | "inbox" | "claim" | "release" | "join" | "leave" | "watch" | "name";
82
+ /** With join/leave: the split and the unit, so a resumed transcript still says which. */
83
+ team?: string;
84
+ unit?: string;
85
+ status?: SessionStatus;
86
+ delivered?: string[];
87
+ messageId?: string;
88
+ messages?: BusMessage[];
89
+ claimed?: string[];
90
+ conflicts?: ClaimConflict[];
91
+ released?: string[];
92
+ notOwned?: string[];
93
+ /** Whether the inbox watch is on, and whether it raises a turn. */
94
+ watching?: { armed: boolean; wake: boolean };
95
+ /** With name: the label the bus settled on, which is what this session is called. */
96
+ label?: string;
97
+ error?: string;
98
+ }
@@ -210,7 +210,7 @@ for another agent (typically `worker`) to execute, and it works headlessly. It
210
210
  returns the plan as text and writes nothing. For **interactive** planning where
211
211
  the user reviews and approves a saved plan, use the plan-mode extension instead
212
212
  (`enter_plan_mode` / `write_plan` / `exit_plan_mode`), which operates on the
213
- main session and persists to `.pi/plans`. Plan mode keeps `subagent` available
213
+ main session and persists to the agent directory. Plan mode keeps `subagent` available
214
214
  for exploration but forces it read-only, so delegated recon is safe mid-plan.
215
215
 
216
216
  ## External agents
@@ -3,7 +3,8 @@
3
3
  Tail a file or a command's merged output, match each new line against a regular
4
4
  expression, and keep the matching lines. Matches are sanitized and alerted in
5
5
  the TUI, reported to the model at the next turn, or — when a watcher opts into
6
- `wake` — raise one triggered turn immediately.
6
+ `wake` — raise one triggered turn as soon as the agent is idle, including right
7
+ after a running turn settles.
7
8
 
8
9
  ```
9
10
  pi --extension ./extensions/watch # load just this extension
@@ -29,7 +30,9 @@ How the `watch` call and its result look in the transcript — the call line as
29
30
  - Shows a `⊚ N` running-watcher chip and a `! N` unreported-match chip, raises a
30
31
  `ctx.ui.notify` alert once per unreported burst, and offers a `/watch` screen.
31
32
  - Reports pending matches to the model at the start of the next turn, or wakes a
32
- turn for a watcher added with `wake: true`. A wake also leaves the terminal: the
33
+ turn for a watcher added with `wake: true` as soon as the agent is idle. A wake
34
+ that arrives mid-turn is raised once that run settles. A wake also leaves the
35
+ terminal: the
33
36
  match is announced on `my-pi-agent/watch-match`, which bytefly's
34
37
  [notify](../bytefly/README.md#notifications) capability turns into a desktop
35
38
  notification when `notify.enabled` and `notify.matchFound` are on.
@@ -172,8 +175,8 @@ other.
172
175
  - Matching is per line; a pattern cannot span a newline.
173
176
  - Only a **wake** match raises an OS notification, and only when bytefly's
174
177
  `notify.enabled` and `notify.matchFound` are on. A match collected while the
175
- agent was busy is reported at the next turn without a notification, and `watch`
176
- itself never sends one — it announces the match and bytefly owns delivery.
178
+ agent was busy notifies once that run settles, and `watch` itself never sends
179
+ one — it announces the match and bytefly owns delivery.
177
180
 
178
181
  ## Non-goals
179
182
 
@@ -213,7 +216,8 @@ UTF-8 sequence split across a read boundary is never mangled. State is
213
216
  session-only by design: a watcher is a live process or file position, not
214
217
  transcript state, so it cannot be faithfully rebuilt from a `/tree` fork. Match
215
218
  delivery mirrors `job` — reported at the next turn, with `wake` triggering one
216
- turn — so a match is never dropped when the wake window is busy. Stopping a
219
+ turn as soon as the agent is idle — so a match is never dropped when the wake
220
+ window is busy; a mid-turn wake is raised once that run settles. Stopping a
217
221
  command sends SIGTERM to its process group and escalates to SIGKILL after a 2 s
218
222
  grace; shutdown waits the same grace, so a command that ignores SIGTERM cannot
219
223
  outlive the session.
@@ -4,8 +4,9 @@
4
4
  * Registers the `watch` tool and `/watch` command. Watchers read a file or a
5
5
  * command's merged output, match each new line against a regular expression,
6
6
  * and alert the user; matching lines are reported to the model — and shown as a
7
- * bytefly notice — at the next turn, or wake a turn immediately when the
8
- * watcher opted into `wake`. Watchers are session-only and stopped on shutdown.
7
+ * bytefly notice — at the next turn, or wake a turn as soon as the agent is idle
8
+ * when the watcher opted into `wake`, including right after a running turn
9
+ * settles. Watchers are session-only and stopped on shutdown.
9
10
  *
10
11
  * Load with: pi --extension ./extensions/watch
11
12
  */
@@ -14,7 +15,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
15
  import { isExtensionEnabled } from "../../lib/env.ts";
15
16
  import { WATCH_MATCH_CHANNEL, type WatchMatchEvent } from "../../lib/events.ts";
16
17
  import { sanitizeLine } from "../../lib/format.ts";
17
- import { noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
18
+ import { burstText, noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
18
19
  import { createPendingReporter, registerPendingChannel } from "../../lib/report.ts";
19
20
  import { registerCommands } from "./commands.ts";
20
21
  import { clipLabel } from "../../lib/tui/columns.ts";
@@ -32,10 +33,14 @@ const MAX_CONTEXT_MATCHES = 20;
32
33
  /** Build the model-facing text for the pending-match notice. */
33
34
  export function buildWatchContext(matches: PendingMatch[]): string {
34
35
  const shown = matches.slice(0, MAX_CONTEXT_MATCHES);
35
- const lines = shown.map(formatPendingMatch);
36
36
  const more = matches.length - shown.length;
37
- const suffix = more > 0 ? `\n… ${more} more match${more === 1 ? "" : "es"}` : "";
38
- return `${WATCH_CONTEXT_MARKER}\n${matches.length} watched line${matches.length === 1 ? "" : "s"} matched:\n${lines.join("\n")}${suffix}\nRead more with \`watch matches <id>\`.`;
37
+ return burstText({
38
+ marker: WATCH_CONTEXT_MARKER,
39
+ headline: `${matches.length} watched line${matches.length === 1 ? "" : "s"} matched`,
40
+ lines: shown.map((match) => formatPendingMatch(match)),
41
+ ...(more > 0 ? { more: `… ${more} more match${more === 1 ? "" : "es"}` } : {}),
42
+ hint: "Read more with `watch matches <id>`.",
43
+ });
39
44
  }
40
45
 
41
46
  /** The shared notice for pending matches: model text plus renderer details. */
@@ -64,15 +69,16 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
64
69
  if (!isExtensionEnabled("watch")) return;
65
70
  const runtime = deps.runtime ?? createWatchRuntime();
66
71
 
67
- // Opt-in wake: a `wake` watcher matched while the agent is idle. The reporter
68
- // reports *all* pending matches, not just the waking one, so a non-wake match
69
- // is not silently dropped.
72
+ // Opt-in wake: a `wake` watcher matched. The reporter reports *all* pending
73
+ // matches, not just the waking one, so a non-wake match is not silently
74
+ // dropped, and it retries a burst that arrived mid-turn once the run settles.
70
75
  const reporter = createPendingReporter<PendingMatch>({
71
76
  wake: (notice) => pi.sendMessage(notice, { triggerTurn: true }),
72
77
  pending: () => runtime.pendingMatches(),
73
78
  markReported: (matches) => runtime.markReported(matches),
74
79
  buildNotice: watchNotice,
75
80
  source: "watch",
81
+ wakeRequested: () => runtime.pendingMatches().some((match) => runtime.get(match.watcherId)?.wake === true),
76
82
  // A wake means the user is away from the terminal, so the alert also leaves
77
83
  // the process. bytefly's notify capability owns delivery; this publishes the
78
84
  // fact and never learns how it is sent. The newest match is the one an alert