@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 -11
- package/README.zh-CN.md +13 -8
- package/docs/extensions/README.md +1 -1
- package/docs/extensions/board.md +286 -123
- package/docs/extensions/bytefly.md +381 -223
- package/docs/extensions/file-browse.md +14 -7
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +56 -11
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +45 -17
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +23 -5
- package/extensions/board/format.ts +107 -9
- package/extensions/board/join.ts +1 -1
- package/extensions/board/page.ts +170 -94
- package/extensions/board/result.ts +9 -0
- package/extensions/board/schema.ts +22 -7
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +1 -0
- package/extensions/board/tools.ts +186 -21
- package/extensions/board/transport/router.ts +27 -108
- package/extensions/board/transport/server.ts +18 -29
- package/extensions/board/types.ts +67 -0
- package/extensions/board/view/client.ts +23 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +5 -171
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +48 -88
- package/extensions/board/view/render.ts +213 -190
- package/extensions/board/view/styles.ts +65 -48
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/transport/router.ts +0 -2
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +6 -7
- package/extensions/file-browse/view/markdown.ts +11 -13
- package/extensions/file-browse/view/raw-html.ts +18 -13
- package/extensions/job/runtime.ts +4 -4
- package/extensions/plan/feedback.ts +40 -16
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +17 -1
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +31 -2
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -1
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +41 -8
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +6 -5
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +86 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/store.ts +239 -52
- package/lib/board/view.ts +94 -10
- package/lib/format.ts +22 -0
- package/lib/http/serve.ts +3 -0
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +14 -1
- package/lib/session-roster.ts +74 -3
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/glyphs.ts +2 -3
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/package.json +1 -1
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
package/lib/board/view.ts
CHANGED
|
@@ -13,16 +13,25 @@
|
|
|
13
13
|
* Pure: the caller supplies the cards, the clock, and the sizes it read.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { type RosterSession, rosterStateOf } from "../session-roster.ts";
|
|
17
|
-
import {
|
|
16
|
+
import { type RosterSession, type RosterState, rosterStateOf } from "../session-roster.ts";
|
|
17
|
+
import {
|
|
18
|
+
type Card,
|
|
19
|
+
CARD_STATUSES,
|
|
20
|
+
type CardStatus,
|
|
21
|
+
laneSince,
|
|
22
|
+
priorityOf,
|
|
23
|
+
type Priority,
|
|
24
|
+
signatureOf,
|
|
25
|
+
} from "./card.ts";
|
|
26
|
+
import { blockersOf, indexOf, lanesText, openDependentsOf } from "./links.ts";
|
|
18
27
|
import type { Actor } from "./rules.ts";
|
|
19
28
|
import { buildRound, type RoundSummary } from "./round.ts";
|
|
20
29
|
|
|
21
30
|
/** One session on the repository's bus, as the Team tab lists it. */
|
|
22
31
|
export interface ViewTeammate {
|
|
23
32
|
label: string;
|
|
24
|
-
/** `live` while its heartbeat holds, `quiet` once the record has aged out. */
|
|
25
|
-
state:
|
|
33
|
+
/** `live` while its heartbeat holds, `quiet` once the record has aged out, `gone` once its process is not running. */
|
|
34
|
+
state: RosterState;
|
|
26
35
|
/** When it last proved it was there. */
|
|
27
36
|
seen: number;
|
|
28
37
|
/** Messages waiting in its inbox. */
|
|
@@ -33,10 +42,47 @@ export interface ViewTeammate {
|
|
|
33
42
|
you: boolean;
|
|
34
43
|
}
|
|
35
44
|
|
|
45
|
+
/**
|
|
46
|
+
* One card as the page meets it: the stored card, plus the two answers it would
|
|
47
|
+
* otherwise compute for itself.
|
|
48
|
+
*
|
|
49
|
+
* Both are derived from the board, not stored on the card, so a client that did the
|
|
50
|
+
* same work would be a second copy of a rule — and the copies drift, which is how the
|
|
51
|
+
* page came to disagree with the tool about what a card waits for.
|
|
52
|
+
*/
|
|
53
|
+
export interface ViewCard extends Card {
|
|
54
|
+
/** The priority it acts on: a card that never set one is ordinary. */
|
|
55
|
+
priority: Priority;
|
|
56
|
+
/**
|
|
57
|
+
* The unfinished cards it waits for, as `#2 (doing)`; absent when nothing blocks it.
|
|
58
|
+
*
|
|
59
|
+
* `undefined` is the whole fact — a waiting card is one with a `waits` — because a
|
|
60
|
+
* boolean beside it would be one more thing that can disagree with the string.
|
|
61
|
+
*/
|
|
62
|
+
waits?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The unfinished cards waiting on it, as `#9 (todo)`; absent when none do.
|
|
65
|
+
*
|
|
66
|
+
* The other side of `waits`: what leaving this card undone holds up. Derived here
|
|
67
|
+
* rather than in the page's script for the same reason, and filtered to the unfinished
|
|
68
|
+
* for the same reason a finished blocker does not count.
|
|
69
|
+
*/
|
|
70
|
+
dependents?: string;
|
|
71
|
+
/**
|
|
72
|
+
* When the card entered the lane it is in now, or absent when its history does not say.
|
|
73
|
+
*
|
|
74
|
+
* A timestamp rather than a phrase: the page already formats ages, and a wording
|
|
75
|
+
* decided here would be a second copy of that rule. Absent is the honest answer for a
|
|
76
|
+
* card written before the log existed, so the page draws no age rather than guessing
|
|
77
|
+
* one from `updatedAt`, which moves on every write.
|
|
78
|
+
*/
|
|
79
|
+
since?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
/** One lane and the cards in it, in the order the page draws them. */
|
|
37
83
|
export interface ViewLane {
|
|
38
84
|
status: CardStatus;
|
|
39
|
-
cards:
|
|
85
|
+
cards: ViewCard[];
|
|
40
86
|
}
|
|
41
87
|
|
|
42
88
|
/** The per-lane tallies the toolbar shows. */
|
|
@@ -56,7 +102,7 @@ export interface BoardView {
|
|
|
56
102
|
session?: { id?: string; label: string };
|
|
57
103
|
lanes: ViewLane[];
|
|
58
104
|
counts: ViewCounts;
|
|
59
|
-
round: RoundSummary
|
|
105
|
+
round: RoundSummary<ViewCard>;
|
|
60
106
|
/**
|
|
61
107
|
* The sessions on this repository's bus, or absent when there is no bus here.
|
|
62
108
|
*
|
|
@@ -66,6 +112,13 @@ export interface BoardView {
|
|
|
66
112
|
teammates?: ViewTeammate[];
|
|
67
113
|
/** Bytes the store occupies, so a board that is never pruned says so. */
|
|
68
114
|
bytes: number;
|
|
115
|
+
/**
|
|
116
|
+
* Card files the reader refused, so the footer can say the board is not empty.
|
|
117
|
+
*
|
|
118
|
+
* Zero is the ordinary answer and draws nothing; a count above it is the one fact
|
|
119
|
+
* that separates "no work here" from "this build cannot read your work".
|
|
120
|
+
*/
|
|
121
|
+
skipped: number;
|
|
69
122
|
/** Changes when a card's revision, a teammate's state, or its mail does. */
|
|
70
123
|
sig: string;
|
|
71
124
|
/** The store's directory, shortened. */
|
|
@@ -86,6 +139,8 @@ export interface ViewInput {
|
|
|
86
139
|
cards: readonly Card[];
|
|
87
140
|
now: number;
|
|
88
141
|
bytes: number;
|
|
142
|
+
/** Card files the reader refused; absent reads as none, which is the ordinary board. */
|
|
143
|
+
skipped?: number;
|
|
89
144
|
where: string;
|
|
90
145
|
/** The bus's records, when the page could read them at all. */
|
|
91
146
|
roster?: readonly RosterSession[];
|
|
@@ -93,9 +148,16 @@ export interface ViewInput {
|
|
|
93
148
|
|
|
94
149
|
/** Build the page's snapshot. */
|
|
95
150
|
export function buildView(input: ViewInput): BoardView {
|
|
151
|
+
// One index for the whole snapshot: a card's lane and its blockers are read from the
|
|
152
|
+
// same board, so a dependency and its dependent cannot be told two different stories.
|
|
153
|
+
const index = indexOf(input.cards);
|
|
154
|
+
// One mapped list feeds both halves of the snapshot: the rollup's groups carry the same
|
|
155
|
+
// card the lane does, so a client reading `round.unassigned` is not handed a card
|
|
156
|
+
// missing the facts every other card has.
|
|
157
|
+
const cards = input.cards.map((card) => viewCardOf(card, index));
|
|
96
158
|
const lanes = CARD_STATUSES.map((status) => ({
|
|
97
159
|
status,
|
|
98
|
-
cards:
|
|
160
|
+
cards: cards.filter((card) => card.status === status),
|
|
99
161
|
}));
|
|
100
162
|
const reader = input.session ?? input.me;
|
|
101
163
|
const teammates = input.roster === undefined ? undefined : teammatesOf(input.roster, reader.label, input.now);
|
|
@@ -104,16 +166,38 @@ export function buildView(input: ViewInput): BoardView {
|
|
|
104
166
|
...(input.session === undefined ? {} : { session: input.session }),
|
|
105
167
|
lanes,
|
|
106
168
|
counts: countOf(lanes),
|
|
107
|
-
round: buildRound(
|
|
169
|
+
round: buildRound(cards, input.now),
|
|
108
170
|
...(teammates === undefined ? {} : { teammates }),
|
|
109
171
|
bytes: input.bytes,
|
|
172
|
+
skipped: input.skipped ?? 0,
|
|
110
173
|
// The roster enters the signature at its coarse facts only: a heartbeat every ten
|
|
111
|
-
// seconds is not news, while a join, a quiet and a letter are.
|
|
112
|
-
|
|
174
|
+
// seconds is not news, while a join, a quiet and a letter are. A file that became
|
|
175
|
+
// readable, or unreadable, is news too: the footer counts it, and a frame that is
|
|
176
|
+
// not pushed leaves the page saying the board holds less than it does.
|
|
177
|
+
sig: signatureOf(input.cards) + rosterSignature(teammates) + `|skipped:${input.skipped ?? 0}`,
|
|
113
178
|
where: input.where,
|
|
114
179
|
};
|
|
115
180
|
}
|
|
116
181
|
|
|
182
|
+
/**
|
|
183
|
+
* One card for the page: what it carries, plus what it acts on.
|
|
184
|
+
*
|
|
185
|
+
* The card is copied rather than annotated — the caller's cards are the store's, and a
|
|
186
|
+
* derived field written onto one would outlive the read that made it.
|
|
187
|
+
*/
|
|
188
|
+
function viewCardOf(card: Card, index: ReadonlyMap<string, Card>): ViewCard {
|
|
189
|
+
const waits = lanesText(blockersOf(card, index));
|
|
190
|
+
const dependents = lanesText(openDependentsOf(card.id, index));
|
|
191
|
+
const since = laneSince(card);
|
|
192
|
+
return {
|
|
193
|
+
...card,
|
|
194
|
+
priority: priorityOf(card),
|
|
195
|
+
...(waits === "" ? {} : { waits }),
|
|
196
|
+
...(dependents === "" ? {} : { dependents }),
|
|
197
|
+
...(since === undefined ? {} : { since }),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
117
201
|
/** The roster as the page shows it, ordered so a repaint never reorders it. */
|
|
118
202
|
function teammatesOf(roster: readonly RosterSession[], meLabel: string, now: number): ViewTeammate[] {
|
|
119
203
|
return roster
|
package/lib/format.ts
CHANGED
|
@@ -53,6 +53,28 @@ export function formatDuration(ms: number): string {
|
|
|
53
53
|
return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* A coarse age, the way a reader says it: `now`, `3m`, `2h`, `5d`.
|
|
58
|
+
*
|
|
59
|
+
* One rule for both surfaces that print an age — a card's offer in the transcript, and
|
|
60
|
+
* the same card on the page — so one fact cannot read `2h ago` in a tool result and `3h`
|
|
61
|
+
* on the page. Written as concatenation because the page's bundle carries this function's
|
|
62
|
+
* *source* (`FORMAT_AGE_JS`): a backtick or an interpolation in it would end the string it
|
|
63
|
+
* is spliced into.
|
|
64
|
+
*/
|
|
65
|
+
export function formatAge(now: number, at: number): string {
|
|
66
|
+
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
|
67
|
+
if (seconds < 45) return "now";
|
|
68
|
+
const minutes = Math.round(seconds / 60);
|
|
69
|
+
if (minutes < 60) return minutes + "m";
|
|
70
|
+
const hours = Math.round(minutes / 60);
|
|
71
|
+
if (hours < 24) return hours + "h";
|
|
72
|
+
return Math.round(hours / 24) + "d";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** {@link formatAge} as the page's bundle receives it. */
|
|
76
|
+
export const FORMAT_AGE_JS = String(formatAge);
|
|
77
|
+
|
|
56
78
|
/** `1 tool`, `3 tools`: a tool count with the right word. */
|
|
57
79
|
export function formatToolCount(count: number): string {
|
|
58
80
|
const n = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
|
package/lib/http/serve.ts
CHANGED
|
@@ -218,6 +218,9 @@ export async function serveLoopback(options: LoopbackOptions): Promise<LoopbackS
|
|
|
218
218
|
});
|
|
219
219
|
});
|
|
220
220
|
const port = await bindFirst(server, host, options.ports);
|
|
221
|
+
// The socket is bound but no page may ever read it: a server that asked for a first
|
|
222
|
+
// grace arms it here, so a browser that never came up cannot hold the port all session.
|
|
223
|
+
sinks.start();
|
|
221
224
|
const closeServer = createCloser(server);
|
|
222
225
|
let closed = false;
|
|
223
226
|
return {
|
package/lib/http/sink.ts
CHANGED
|
@@ -22,12 +22,23 @@ export interface EventSink {
|
|
|
22
22
|
end(frame?: string): void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Why a server went idle: no stream ever opened, or the last one left. */
|
|
26
|
+
export type IdleReason = "first" | "last";
|
|
27
|
+
|
|
25
28
|
/** The idle policy a server was given, or none. */
|
|
26
29
|
export interface IdleGrace {
|
|
27
30
|
/** How long the last stream's departure is held, in ms. */
|
|
28
31
|
ms: number;
|
|
29
|
-
/**
|
|
30
|
-
|
|
32
|
+
/**
|
|
33
|
+
* How long a server may stand with no stream ever opened, in ms.
|
|
34
|
+
*
|
|
35
|
+
* A page opened in a browser that never came up would otherwise hold its port for the
|
|
36
|
+
* rest of the session. Omitted, the grace still waits for the first stream, which is
|
|
37
|
+
* what a server whose page is opened by hand wants.
|
|
38
|
+
*/
|
|
39
|
+
firstMs?: number;
|
|
40
|
+
/** Called once no stream has been open for the grace that applied. */
|
|
41
|
+
onIdle: (reason: IdleReason) => void;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
/** What the registry needs besides the streams themselves. */
|
|
@@ -44,6 +55,8 @@ export interface SinkRegistry {
|
|
|
44
55
|
open(response: ServerResponse): EventSink;
|
|
45
56
|
/** How many streams are open. */
|
|
46
57
|
size(): number;
|
|
58
|
+
/** Arm the grace for a server nobody may ever read. Does nothing without `firstMs`. */
|
|
59
|
+
start(): void;
|
|
47
60
|
/** End every open stream, because the server is closing. */
|
|
48
61
|
endAll(): void;
|
|
49
62
|
}
|
|
@@ -52,20 +65,24 @@ export function createSinks(deps: SinkDeps = {}): SinkRegistry {
|
|
|
52
65
|
const streams = new Set<EventSink>();
|
|
53
66
|
const timer = deps.timer ?? defaultTimer;
|
|
54
67
|
let cancelIdle: (() => void) | undefined;
|
|
55
|
-
/** A
|
|
68
|
+
/** A stream has attached at least once, so a departure is one to wait on. */
|
|
56
69
|
let sawStream = false;
|
|
70
|
+
/** The server asked for the first grace, so it may go idle without a reader. */
|
|
71
|
+
let started = false;
|
|
57
72
|
|
|
58
73
|
const standDown = (): void => {
|
|
59
74
|
cancelIdle?.();
|
|
60
75
|
cancelIdle = undefined;
|
|
61
76
|
};
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (idle === undefined || !sawStream || streams.size > 0 || cancelIdle !== undefined) return;
|
|
66
|
-
cancelIdle = timer(
|
|
78
|
+
/** Arm `ms` from now, unless a stream is open or a grace already runs. */
|
|
79
|
+
const arm = (ms: number, reason: IdleReason): void => {
|
|
80
|
+
if (deps.idle === undefined || (!started && !sawStream) || streams.size > 0 || cancelIdle !== undefined) return;
|
|
81
|
+
cancelIdle = timer(ms, () => {
|
|
67
82
|
cancelIdle = undefined;
|
|
68
|
-
|
|
83
|
+
// A tab that attached while the grace ran has already stood it down, but a
|
|
84
|
+
// second look costs nothing and keeps a race from closing a page being read.
|
|
85
|
+
if (streams.size === 0) deps.idle?.onIdle(reason);
|
|
69
86
|
});
|
|
70
87
|
};
|
|
71
88
|
|
|
@@ -74,12 +91,20 @@ export function createSinks(deps: SinkDeps = {}): SinkRegistry {
|
|
|
74
91
|
standDown();
|
|
75
92
|
sawStream = true;
|
|
76
93
|
const sink = createSink(response, () => {
|
|
77
|
-
if (streams.delete(sink))
|
|
94
|
+
if (!streams.delete(sink)) return;
|
|
95
|
+
const idle = deps.idle;
|
|
96
|
+
if (idle !== undefined) arm(idle.ms, "last");
|
|
78
97
|
});
|
|
79
98
|
streams.add(sink);
|
|
80
99
|
return sink;
|
|
81
100
|
},
|
|
82
101
|
size: () => streams.size,
|
|
102
|
+
start() {
|
|
103
|
+
const idle = deps.idle;
|
|
104
|
+
if (idle?.firstMs === undefined || started || sawStream) return;
|
|
105
|
+
started = true;
|
|
106
|
+
arm(idle.firstMs, "first");
|
|
107
|
+
},
|
|
83
108
|
endAll() {
|
|
84
109
|
standDown();
|
|
85
110
|
const open = [...streams];
|
package/lib/plan-entry.ts
CHANGED
|
@@ -41,6 +41,17 @@ export const PLAN_CONSENT_SHORT =
|
|
|
41
41
|
"Enter read-only plan mode? Writes and mutating tools are disabled until you approve a plan.";
|
|
42
42
|
export const PLAN_CONSENT_SHORT_ZH = "进入只读 plan 模式?批准计划前会禁用写入和修改。";
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The refusal a command shows when plan mode forbids what it does.
|
|
46
|
+
*
|
|
47
|
+
* Plan mode is read-only, so every extension whose command changes something says so the
|
|
48
|
+
* same way and names the action it is not doing: one sentence in `lib/`, because five
|
|
49
|
+
* copies is how the guarantee stops reading as one guarantee.
|
|
50
|
+
*/
|
|
51
|
+
export function planModeRefusal(action: string): string {
|
|
52
|
+
return `Plan mode is read-only. Approve the plan before ${action}.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
/** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
|
|
45
56
|
export interface PlanEnterRequest {
|
|
46
57
|
/** The caller's context; `plan` uses it for the footer status. */
|
package/lib/policy.ts
CHANGED
|
@@ -15,7 +15,20 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { READ_DOC_TOOL } from "./tool-names.ts";
|
|
18
|
+
import { BOARD_READ_ACTION, READ_DOC_TOOL } from "./tool-names.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a call to the `board` tool only reads it.
|
|
22
|
+
*
|
|
23
|
+
* The board is one tool over ten actions, so its `readOnlyHint` says "may write" and
|
|
24
|
+
* cannot answer this. The action can, and it travels in the call's input — and in the
|
|
25
|
+
* result's `details`, which is all a `tool_execution_end` event carries. Plan mode
|
|
26
|
+
* asks before the call and the todo nudge after it, so the answer lives here once.
|
|
27
|
+
*/
|
|
28
|
+
export function isBoardRead(call: unknown): boolean {
|
|
29
|
+
if (typeof call !== "object" || call === null) return false;
|
|
30
|
+
return (call as { action?: unknown }).action === BOARD_READ_ACTION;
|
|
31
|
+
}
|
|
19
32
|
|
|
20
33
|
/**
|
|
21
34
|
* Tools that only read the workspace: the built-in structured readers and the
|
package/lib/session-roster.ts
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* here once, and `session-bus` imports them, so a layout change cannot land in one
|
|
10
10
|
* copy and not the other.
|
|
11
11
|
*
|
|
12
|
+
* The rule answers three things, not two: a record whose process is gone is `gone` at
|
|
13
|
+
* once, rather than aging into `quiet` for ten minutes while the paths it claimed stay
|
|
14
|
+
* held and the cards it held read `idle`.
|
|
15
|
+
*
|
|
12
16
|
* A missing directory is an answer, not an error: it means no session has ever
|
|
13
17
|
* joined this repository, and a caller renders that differently from an empty bus.
|
|
14
18
|
*
|
|
@@ -18,6 +22,7 @@
|
|
|
18
22
|
|
|
19
23
|
import { readdir, readFile } from "node:fs/promises";
|
|
20
24
|
import { join } from "node:path";
|
|
25
|
+
import { isPidAlive, type LivenessFn, startTokenOf, type StartTokenFn } from "./spawn.ts";
|
|
21
26
|
|
|
22
27
|
/** The bus directory under the agent dir, `session-bus`. */
|
|
23
28
|
export const BUS_DIR_NAME = "session-bus";
|
|
@@ -37,6 +42,16 @@ export interface RosterSession {
|
|
|
37
42
|
lastSeen: number;
|
|
38
43
|
/** Messages waiting in its inbox. */
|
|
39
44
|
mail: number;
|
|
45
|
+
/**
|
|
46
|
+
* The process that owns the record, when the record names one.
|
|
47
|
+
*
|
|
48
|
+
* Absent is "cannot tell": a record written before this existed, or by a mode that
|
|
49
|
+
* is not a local process, is judged by its heartbeat alone rather than being called
|
|
50
|
+
* dead on no evidence.
|
|
51
|
+
*/
|
|
52
|
+
pid?: number;
|
|
53
|
+
/** That process's start time, as the bus wrote it, when the record carries it. */
|
|
54
|
+
startToken?: string;
|
|
40
55
|
/**
|
|
41
56
|
* Whether the session is listening for mail, when its record says.
|
|
42
57
|
*
|
|
@@ -46,9 +61,56 @@ export interface RosterSession {
|
|
|
46
61
|
watching?: { armed: boolean; wake: boolean };
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
/**
|
|
50
|
-
export
|
|
51
|
-
|
|
64
|
+
/** What a record's heartbeat and its process say together. */
|
|
65
|
+
export type RosterState = "live" | "quiet" | "gone";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What a record's heartbeat and its process say together.
|
|
69
|
+
*
|
|
70
|
+
* The fields the rule reads, rather than the whole roster row: `session-bus` judges its
|
|
71
|
+
* own records by the same rule without carrying a `mail` count it never shows.
|
|
72
|
+
*/
|
|
73
|
+
export type RosterFacts = Pick<RosterSession, "lastSeen" | "pid" | "startToken">;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The host facts {@link rosterStateOf} reads, injectable so every branch is testable.
|
|
77
|
+
*
|
|
78
|
+
* The token spawns a process, so it is asked for only where liveness cannot decide.
|
|
79
|
+
*/
|
|
80
|
+
export interface RosterSeams {
|
|
81
|
+
alive?: LivenessFn;
|
|
82
|
+
token?: StartTokenFn;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What one record says about its session: alive, aged out, or attached to nothing.
|
|
87
|
+
*
|
|
88
|
+
* Three answers, in the order they are decided:
|
|
89
|
+
*
|
|
90
|
+
* - `gone` — the record names a process and that process is not running. A heartbeat
|
|
91
|
+
* written ten seconds before a crash is not evidence the process exists, so death
|
|
92
|
+
* outranks a fresh one.
|
|
93
|
+
* - `live` — the heartbeat is fresh. When the pid is alive too, the start token has not
|
|
94
|
+
* been asked for yet: a session that wrote ten seconds ago is not the interesting
|
|
95
|
+
* case for pid reuse, and `ps` costs a process.
|
|
96
|
+
* - `quiet` — the heartbeat has aged. If the pid is alive but its start time is not the
|
|
97
|
+
* one the record wrote, the process is somebody else's and the session is `gone`
|
|
98
|
+
* after all; a platform that cannot answer the start time leaves it at `quiet`.
|
|
99
|
+
*/
|
|
100
|
+
export function rosterStateOf(
|
|
101
|
+
session: RosterFacts,
|
|
102
|
+
now: number,
|
|
103
|
+
ttlMs = SESSION_TTL_MS,
|
|
104
|
+
seams: RosterSeams = {},
|
|
105
|
+
): RosterState {
|
|
106
|
+
const alive = seams.alive ?? isPidAlive;
|
|
107
|
+
if (session.pid !== undefined && !alive(session.pid)) return "gone";
|
|
108
|
+
if (now - session.lastSeen <= ttlMs) return "live";
|
|
109
|
+
if (session.pid !== undefined && session.startToken !== undefined) {
|
|
110
|
+
const current = (seams.token ?? startTokenOf)(session.pid);
|
|
111
|
+
if (current !== undefined && current !== session.startToken) return "gone";
|
|
112
|
+
}
|
|
113
|
+
return "quiet";
|
|
52
114
|
}
|
|
53
115
|
|
|
54
116
|
/** The fields this reader trusts; anything else in the file is the bus's business. */
|
|
@@ -59,6 +121,11 @@ interface SessionPayload {
|
|
|
59
121
|
lastSeen: number;
|
|
60
122
|
}
|
|
61
123
|
|
|
124
|
+
/** A record's pid, read only when it is the shape a pid has. */
|
|
125
|
+
function pidOf(value: unknown): number | undefined {
|
|
126
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
62
129
|
function isSessionPayload(value: unknown): value is SessionPayload {
|
|
63
130
|
if (typeof value !== "object" || value === null) return false;
|
|
64
131
|
const { id, label, mode, lastSeen } = value as Record<string, unknown>;
|
|
@@ -117,12 +184,16 @@ export async function readRoster(dir: string): Promise<RosterSession[] | undefin
|
|
|
117
184
|
if (!isSessionPayload(parsed)) continue;
|
|
118
185
|
const { id, label, mode, lastSeen } = parsed;
|
|
119
186
|
const watching = watchingOf((parsed as { watching?: unknown }).watching);
|
|
187
|
+
const pid = pidOf((parsed as { pid?: unknown }).pid);
|
|
188
|
+
const rawToken = (parsed as { startToken?: unknown }).startToken;
|
|
120
189
|
sessions.push({
|
|
121
190
|
id,
|
|
122
191
|
label,
|
|
123
192
|
mode,
|
|
124
193
|
lastSeen,
|
|
125
194
|
mail: await mailCount(dir, name.slice(0, -".json".length)),
|
|
195
|
+
...(pid === undefined ? {} : { pid }),
|
|
196
|
+
...(typeof rawToken === "string" && rawToken !== "" ? { startToken: rawToken } : {}),
|
|
126
197
|
...(watching === undefined ? {} : { watching }),
|
|
127
198
|
});
|
|
128
199
|
}
|
package/lib/spawn.ts
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
*
|
|
9
9
|
* IO is split from the decision: {@link spawnOptions} and {@link killPlan} are
|
|
10
10
|
* pure and testable on every platform, while {@link defaultSpawn} and
|
|
11
|
-
* {@link defaultKillTree} execute them.
|
|
12
|
-
*
|
|
11
|
+
* {@link defaultKillTree} execute them. The liveness pair — {@link isPidAlive}
|
|
12
|
+
* and {@link startTokenOf} — is here too, because asking whether a process is
|
|
13
|
+
* still the one a record named is the same question as killing it: `job` guards
|
|
14
|
+
* a reattached pid with it, and so does the session roster. Value-only, like
|
|
15
|
+
* every `lib/` module: no module state, and each caller keeps its own children.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
|
-
import { spawn as nodeSpawn, spawnSync } from "node:child_process";
|
|
18
|
+
import { execFileSync, spawn as nodeSpawn, spawnSync } from "node:child_process";
|
|
16
19
|
import type { Readable } from "node:stream";
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -124,6 +127,37 @@ export function isPidAlive(pid: number, probe: KillProbe = process.kill): boolea
|
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
/** Whether a pid is still alive. */
|
|
131
|
+
export type LivenessFn = (pid: number) => boolean;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Best-effort OS start-time token for a pid, used to detect pid reuse.
|
|
135
|
+
*
|
|
136
|
+
* Returns undefined when the platform cannot provide one (for example Windows, or a
|
|
137
|
+
* process that is already gone), in which case callers fall back to liveness alone.
|
|
138
|
+
*/
|
|
139
|
+
export type StartTokenFn = (pid: number) => string | undefined;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Default start-time token via `ps`. Undefined where `ps` is unavailable.
|
|
143
|
+
*
|
|
144
|
+
* It spawns a process, so a caller asks for it only where liveness alone cannot
|
|
145
|
+
* decide: a pid that is alive may be a different program than the one a record
|
|
146
|
+
* named, and nothing cheaper than the start time can tell them apart.
|
|
147
|
+
*/
|
|
148
|
+
export const startTokenOf: StartTokenFn = (pid) => {
|
|
149
|
+
try {
|
|
150
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
151
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
152
|
+
timeout: 2000,
|
|
153
|
+
});
|
|
154
|
+
const text = out.toString().trim();
|
|
155
|
+
return text || undefined;
|
|
156
|
+
} catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
127
161
|
/** The host seams {@link applyKillPlan} runs through. */
|
|
128
162
|
export interface KillSeams {
|
|
129
163
|
/** Signal one pid; throws when the pid is gone or not ours. */
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A field a tool call carries but its action does not act on.
|
|
3
|
+
*
|
|
4
|
+
* An action-shaped tool publishes one flat parameter object, so every field is
|
|
5
|
+
* offered to every action at once; that is harmless only while each action reads
|
|
6
|
+
* its own. A field that reaches an action which never reads it used to be dropped
|
|
7
|
+
* in silence — `board add` with `dependsOn` wrote the card without the link and
|
|
8
|
+
* answered success, and `team offer` with `to` broadcast to every live session
|
|
9
|
+
* while the caller believed it had named one. The caller could not tell what was
|
|
10
|
+
* recorded from what was ignored, and nothing in the result said which.
|
|
11
|
+
*
|
|
12
|
+
* So the table of what each action reads lives with the tool (`ACTION_FIELDS`),
|
|
13
|
+
* and this is the sentence a call is refused with: it names every stray field and
|
|
14
|
+
* the action that does take it, which makes the retry one call away. The table is
|
|
15
|
+
* keyed in the order the actions are documented, because that order is how the
|
|
16
|
+
* message lists the verbs that take a field.
|
|
17
|
+
*
|
|
18
|
+
* Value-only, as `lib/` must be: the tables are the callers'.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The fields each action reads, keyed in the order the actions are documented. */
|
|
22
|
+
export type ActionFields = Readonly<Record<string, readonly string[]>>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Why a call carries a field its action does not act on, or nothing when it is clean.
|
|
26
|
+
*
|
|
27
|
+
* `undefined` is not a stray: an optional field the caller left out was never passed,
|
|
28
|
+
* and a schema makes every unset one `undefined`.
|
|
29
|
+
*/
|
|
30
|
+
export function strayField(action: string, args: Record<string, unknown>, fields: ActionFields): string | undefined {
|
|
31
|
+
const mine = fields[action] ?? [];
|
|
32
|
+
const stray = Object.entries(args)
|
|
33
|
+
.filter(([field, value]) => value !== undefined && field !== "action" && !mine.includes(field))
|
|
34
|
+
.map(([field]) => field);
|
|
35
|
+
if (stray.length === 0) return undefined;
|
|
36
|
+
// One stray reads as "takes it"; several need their names, or the sentence stops
|
|
37
|
+
// saying which field goes where.
|
|
38
|
+
const named = (field: string): string => {
|
|
39
|
+
const taking = Object.keys(fields).filter((candidate) => fields[candidate]?.includes(field));
|
|
40
|
+
if (taking.length === 0) return `${field} belongs to no action`;
|
|
41
|
+
const verbs = taking.map((one) => `\`${one}\``).join(" and ");
|
|
42
|
+
return `${verbs} ${taking.length === 1 ? "takes" : "take"} ${stray.length === 1 ? "it" : field}`;
|
|
43
|
+
};
|
|
44
|
+
return `${action} does not act on ${stray.join(", ")}: ${stray.map(named).join("; ")}.`;
|
|
45
|
+
}
|
package/lib/tool-names.ts
CHANGED
|
@@ -45,6 +45,19 @@ export const SESSION_TOOL = "session";
|
|
|
45
45
|
*/
|
|
46
46
|
export const SUBAGENT_READ_ONLY_PARAM = "readOnly";
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The `board` tool: the shared feature list. Plan mode keeps it active because its
|
|
50
|
+
* `list` action is a read of the repository's cards, so the name is shared while the
|
|
51
|
+
* write actions stay the board's to refuse.
|
|
52
|
+
*/
|
|
53
|
+
export const BOARD_TOOL = "board";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The one `board` action that reads. Plan mode admits it and refuses the rest, which
|
|
57
|
+
* is the distinction the tool's `readOnlyHint` cannot carry: the same tool writes.
|
|
58
|
+
*/
|
|
59
|
+
export const BOARD_READ_ACTION = "list";
|
|
60
|
+
|
|
48
61
|
/**
|
|
49
62
|
* The `read_doc` tool. `file-read` registers it, and the plan, rewind, and
|
|
50
63
|
* subagent reader lists carry the name, so the document reader is one reader
|
package/lib/tui/glyphs.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const GLYPHS = {
|
|
|
30
30
|
jobsFailure: "✗",
|
|
31
31
|
stopped: "⊘",
|
|
32
32
|
browse: "◉",
|
|
33
|
-
board: "
|
|
33
|
+
board: "⊞",
|
|
34
34
|
delegation: "↳",
|
|
35
35
|
wechat: "✆",
|
|
36
36
|
gaugeFull: "▰",
|
|
@@ -57,10 +57,9 @@ export const GLYPHS = {
|
|
|
57
57
|
/** Columns between a rail body glyph and its text. */
|
|
58
58
|
export const GLYPH_GAP = 1;
|
|
59
59
|
|
|
60
|
-
/** Padding used to join
|
|
60
|
+
/** Padding used to join UI segments. */
|
|
61
61
|
export const SEPARATORS = {
|
|
62
62
|
item: " · ",
|
|
63
|
-
group: ` ${GLYPHS.divider} `,
|
|
64
63
|
} as const;
|
|
65
64
|
|
|
66
65
|
/** `ctx.ui.setStatus` keys; one per extension that publishes a chip. */
|