@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.
- package/README.md +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- 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
|
|
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
|
|
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
|
|
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
|
|
176
|
-
|
|
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
|
|
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
|
|
8
|
-
* watcher opted into `wake
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|