@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
|
@@ -35,12 +35,24 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
|
|
|
35
35
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
36
36
|
const word = args.trim().toLowerCase();
|
|
37
37
|
if (word === "status") {
|
|
38
|
-
ctx.ui.notify(statusLine(page.status(), joined), "info");
|
|
38
|
+
ctx.ui.notify(statusLine(await page.status(), joined), "info");
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
41
|
if (word === "stop") {
|
|
42
|
-
const status = page.status();
|
|
43
|
-
// A
|
|
42
|
+
const status = await page.status();
|
|
43
|
+
// A note nobody answers is not a page to leave for the next session to trip over:
|
|
44
|
+
// it is dropped here, and the line says whose it was.
|
|
45
|
+
if (status.stale === true) {
|
|
46
|
+
const dropped = await page.dropStale(ctx);
|
|
47
|
+
ctx.ui.notify(
|
|
48
|
+
dropped
|
|
49
|
+
? `board: dropped ${status.seat}'s note for :${status.port} — it answered nobody`
|
|
50
|
+
: `board: nothing to stop here — ${status.seat} is answering on :${status.port}`,
|
|
51
|
+
"info",
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// A page another session serves is that session's to stop, and saying whose it is is the answer.
|
|
44
56
|
if (status.attached === true) {
|
|
45
57
|
ctx.ui.notify(
|
|
46
58
|
`board: nothing to stop here — ${status.seat} serves this repository on :${status.port}`,
|
|
@@ -64,10 +76,13 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
|
|
|
64
76
|
// this session's own label when it watches a page somebody else serves.
|
|
65
77
|
if (started.kind === "failed") return;
|
|
66
78
|
joined = await team.join(ctx, joinLabel(started.kind === "served"));
|
|
79
|
+
// The address is on the line because the browser is not the only way in: a machine
|
|
80
|
+
// with no default browser prints it here, and a tab closed by mistake is one
|
|
81
|
+
// command from being opened again.
|
|
67
82
|
ctx.ui.notify(
|
|
68
83
|
started.kind === "attached"
|
|
69
|
-
? `board: ${started.seat} is serving this repository on :${started.port} — viewing${teamLine(joined)}`
|
|
70
|
-
: `board: ${started.seat} serving ${started.where} on :${started.port}${teamLine(joined)}`,
|
|
84
|
+
? `board: ${started.seat} is serving this repository on :${started.port} — viewing · open ${started.url}${teamLine(joined)}`
|
|
85
|
+
: `board: ${started.seat} serving ${started.where} on :${started.port} · open ${started.url}${teamLine(joined)}`,
|
|
71
86
|
"info",
|
|
72
87
|
);
|
|
73
88
|
},
|
|
@@ -80,6 +95,9 @@ export function statusLine(status: BoardPageStatus, team?: JoinResult): string {
|
|
|
80
95
|
const watching = plural(status.watchers ?? 0, "tab");
|
|
81
96
|
return `board: ${status.seat} serving on :${status.port} · ${watching} watching · ${status.where}${teamLine(team)}`;
|
|
82
97
|
}
|
|
98
|
+
if (status.stale === true) {
|
|
99
|
+
return `board: watching ${status.seat} on :${status.port} — that note answers nobody; /board stop drops it${teamLine(team)}`;
|
|
100
|
+
}
|
|
83
101
|
if (status.attached === true) {
|
|
84
102
|
return `board: watching ${status.seat} on :${status.port} — nothing to stop here${teamLine(team)}`;
|
|
85
103
|
}
|
|
@@ -12,18 +12,85 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { type Card, cardRef, DEFAULT_PRIORITY, dependsOf, priorityOf } from "../../lib/board/card.ts";
|
|
15
|
-
import type
|
|
15
|
+
import { type BoardIndex, lanesText, openDependentsOf } from "../../lib/board/links.ts";
|
|
16
|
+
import type { Skip } from "../../lib/board/store.ts";
|
|
17
|
+
import { formatAge } from "../../lib/format.ts";
|
|
16
18
|
import { plural } from "../../lib/board/round-text.ts";
|
|
17
19
|
|
|
18
20
|
/** One card: the headline, then the state a reader needs in order to act on it. */
|
|
19
|
-
export function formatCard(card: Card, board?: BoardIndex): string {
|
|
20
|
-
return [`${cardRef(card.id)} ${card.status} · ${card.title}`, ...detailLines(card, board)].join("\n");
|
|
21
|
+
export function formatCard(card: Card, board?: BoardIndex, now?: number): string {
|
|
22
|
+
return [`${cardRef(card.id)} ${card.status} · ${card.title}`, ...detailLines(card, board, now)].join("\n");
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* What a listing cannot show, and therefore has to say.
|
|
27
|
+
*
|
|
28
|
+
* A footnote is the difference between a listing and the board: an omission nobody
|
|
29
|
+
* accounted for reads as a smaller board — or as history that was deleted.
|
|
30
|
+
*/
|
|
31
|
+
export interface ListingNotes {
|
|
32
|
+
/** Finished cards the board holds that a lane-less listing left out. */
|
|
33
|
+
hiddenDone: number;
|
|
34
|
+
/** Cards set aside in the archive, which no board read opens. */
|
|
35
|
+
archived: number;
|
|
36
|
+
/** Card files this build could not read. */
|
|
37
|
+
skipped: readonly Skip[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A board's listing: the cards, then the footnotes a reader cannot see.
|
|
42
|
+
*
|
|
43
|
+
* One body, because the footnotes belong to the listing: a card file this build cannot
|
|
44
|
+
* read, the finished cards a plain listing left out, and the cards an `archive` set aside.
|
|
45
|
+
*/
|
|
46
|
+
export function formatBoard(shown: readonly Card[], notes: ListingNotes): string {
|
|
47
|
+
const lines =
|
|
48
|
+
shown.length === 0 ? [notes.hiddenDone === 0 ? "The board is empty." : "No open cards."] : shown.map(listLine);
|
|
49
|
+
// A finished card is history rather than work, so a listing that names no lane leaves
|
|
50
|
+
// them out; the count is what keeps the omission from reading as a smaller board.
|
|
51
|
+
if (notes.hiddenDone > 0) {
|
|
52
|
+
const them = notes.hiddenDone === 1 ? "it" : "them";
|
|
53
|
+
lines.push(`${plural(notes.hiddenDone, "done card")} hidden; pass status: "done" to read ${them}.`);
|
|
54
|
+
}
|
|
55
|
+
for (const note of [archiveNote(notes.archived), formatSkipped(notes.skipped)]) {
|
|
56
|
+
if (note !== "") lines.push(note);
|
|
57
|
+
}
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The archive's listing: what is set aside, and the files there this build refused.
|
|
63
|
+
*
|
|
64
|
+
* The archive is read on request only, so this has no lane footnote of its own — every
|
|
65
|
+
* card in the pile is finished, and a `done` lane would be a second way to say it.
|
|
66
|
+
*/
|
|
67
|
+
export function formatArchive(shown: readonly Card[], skipped: readonly Skip[]): string {
|
|
68
|
+
const lines = shown.length === 0 ? ["The archive is empty."] : shown.map(listLine);
|
|
69
|
+
const unreadable = formatSkipped(skipped);
|
|
70
|
+
if (unreadable !== "") lines.push(unreadable);
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The one sentence a board listing adds so its archive is not mistaken for deleted history.
|
|
76
|
+
*
|
|
77
|
+
* Empty when nothing is set aside, so the ordinary board says nothing new.
|
|
78
|
+
*/
|
|
79
|
+
export function archiveNote(count: number): string {
|
|
80
|
+
if (count === 0) return "";
|
|
81
|
+
return `${plural(count, "card")} ${count === 1 ? "is" : "are"} set aside in the archive; pass archived: true to read ${count === 1 ? "it" : "them"}.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The files the board could not read, or nothing when it read everything.
|
|
86
|
+
*
|
|
87
|
+
* A skipped file is not an absent one: a board written by a newer build would
|
|
88
|
+
* otherwise read as "nobody has planned anything" while the work sits on disk.
|
|
89
|
+
*/
|
|
90
|
+
export function formatSkipped(skipped: readonly Skip[]): string {
|
|
91
|
+
if (skipped.length === 0) return "";
|
|
92
|
+
const files = skipped.map((entry) => `${entry.file} (${entry.why})`).join(", ");
|
|
93
|
+
return `${plural(skipped.length, "file")} this build cannot read: ${files}`;
|
|
27
94
|
}
|
|
28
95
|
|
|
29
96
|
/** The dropped id, which is the only thing left of the card. */
|
|
@@ -31,22 +98,53 @@ export function formatDropped(id: string): string {
|
|
|
31
98
|
return `${cardRef(id)} dropped, with its notes.`;
|
|
32
99
|
}
|
|
33
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The ids an archive set aside, and how to read them again.
|
|
103
|
+
*
|
|
104
|
+
* A move has to name the way back or it reads like a deletion: the card is out of the
|
|
105
|
+
* board and its notes, plan, and landed commit are still in its file.
|
|
106
|
+
*/
|
|
107
|
+
export function formatArchived(ids: readonly string[]): string {
|
|
108
|
+
const theirs = ids.length === 1 ? "its" : "their";
|
|
109
|
+
return `${plural(ids.length, "card")} archived: ${ids.map(cardRef).join(", ")} — out of the board, ${theirs} notes and plan still readable with archived: true.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
34
112
|
/** A one-line listing: the id, the lane, the title, and who holds it. */
|
|
35
113
|
function listLine(card: Card): string {
|
|
36
114
|
const holder = card.owner === undefined ? "unassigned" : card.owner.label;
|
|
37
115
|
const urgency = priorityOf(card) === DEFAULT_PRIORITY ? "" : ` · ${priorityOf(card)}`;
|
|
38
116
|
const waits = dependsOf(card).length === 0 ? "" : ` · waits on ${dependsOf(card).map(cardRef).join(", ")}`;
|
|
39
|
-
|
|
117
|
+
// The two lanes that carry a fact of their own say it here: what a block waits on, and
|
|
118
|
+
// what closed a finished card. Both are one short phrase, so a listing stays one line.
|
|
119
|
+
const blocked = card.blockedBy === undefined ? "" : ` · blocked: ${card.blockedBy}`;
|
|
120
|
+
const landed = card.landed === undefined ? "" : ` · landed ${card.landed.commit}`;
|
|
121
|
+
return `${cardRef(card.id)} ${card.status} · ${card.title} · ${holder}${urgency}${waits}${blocked}${landed}`;
|
|
40
122
|
}
|
|
41
123
|
|
|
42
124
|
/** The state lines under a card's headline. */
|
|
43
|
-
function detailLines(card: Card, board?: BoardIndex): string[] {
|
|
125
|
+
function detailLines(card: Card, board?: BoardIndex, now?: number): string[] {
|
|
44
126
|
const lines: string[] = [];
|
|
45
127
|
if (card.owner !== undefined) lines.push(` owner: ${card.owner.label}`);
|
|
128
|
+
// An offer's age is the fact a reader acts on, which is the one place an age belongs in
|
|
129
|
+
// this text: the value is whether anybody has had time to answer.
|
|
130
|
+
if (card.offeredAt !== undefined && now !== undefined) {
|
|
131
|
+
lines.push(` offered: ${formatAge(now, card.offeredAt)} ago, and nobody took it`);
|
|
132
|
+
}
|
|
133
|
+
if (card.blockedBy !== undefined) lines.push(` blocked: ${card.blockedBy}`);
|
|
134
|
+
if (card.landed !== undefined) lines.push(` landed: ${card.landed.commit}`);
|
|
46
135
|
if (priorityOf(card) !== DEFAULT_PRIORITY) lines.push(` priority: ${priorityOf(card)}`);
|
|
47
136
|
if (card.plan !== undefined) lines.push(` plan: ${card.plan}`);
|
|
48
137
|
if (card.paths.length > 0) lines.push(` paths: ${card.paths.join(", ")}`);
|
|
49
138
|
if (dependsOf(card).length > 0) lines.push(` waits on: ${waitingOf(card, board)}`);
|
|
139
|
+
// The mirror question, for the same board: what leaving this card undone holds up. Only
|
|
140
|
+
// a card with the board in hand can answer it, which is why a bare card read omits it.
|
|
141
|
+
if (board !== undefined) {
|
|
142
|
+
const dependents = openDependentsOf(card.id, board);
|
|
143
|
+
if (dependents.length > 0) {
|
|
144
|
+
const which = dependents.length === 1 ? "card" : "cards";
|
|
145
|
+
lines.push(` waited on by ${dependents.length} ${which}: ${lanesText(dependents)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
50
148
|
if (card.notes.length > 0) {
|
|
51
149
|
const last = card.notes.at(-1);
|
|
52
150
|
if (last) lines.push(` last note (${last.by}): ${last.text}`);
|
package/extensions/board/join.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type { TeamJoin } from "../../lib/session-tool.ts";
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The label `/board` lands on: the page's seat when this session serves it, and
|
|
25
|
-
* nothing when it
|
|
25
|
+
* nothing when it opened somebody else's page, so a session keeps its own name.
|
|
26
26
|
*/
|
|
27
27
|
export function joinLabel(served: boolean): string | undefined {
|
|
28
28
|
return served ? DEFAULT_SEAT : undefined;
|
package/extensions/board/page.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* `session_shutdown`.
|
|
5
5
|
*
|
|
6
6
|
* Only the lifecycle lives here — the state machine, the port it remembers, the chip
|
|
7
|
-
* that names it, and the three closes. What the page *reads* is `snapshot.ts`,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* that names it, and the three closes. What the page *reads* is `snapshot.ts`, and it
|
|
8
|
+
* writes nothing: how a request reaches the snapshot is `transport/`, the router knows
|
|
9
|
+
* nothing of the page's markup, and this is still the one place they meet.
|
|
10
10
|
*
|
|
11
11
|
* All state lives in this closure, not at module scope: Pi loads each extension with an
|
|
12
12
|
* isolated module cache, so a module variable would be one copy per extension.
|
|
@@ -27,14 +27,14 @@ import {
|
|
|
27
27
|
probePage,
|
|
28
28
|
readPointer,
|
|
29
29
|
settlePointer,
|
|
30
|
-
|
|
30
|
+
urlOf,
|
|
31
31
|
} from "../../lib/board/page-pointer.ts";
|
|
32
32
|
import { mintToken } from "../../lib/http/index.ts";
|
|
33
|
+
import type { IdleReason } from "../../lib/http/sink.ts";
|
|
33
34
|
import { join } from "node:path";
|
|
34
35
|
import { type BoardConfig, loadBoardConfig } from "./config.ts";
|
|
35
36
|
import { pageActor } from "./seat.ts";
|
|
36
37
|
import { viewOf } from "./snapshot.ts";
|
|
37
|
-
import { writeOf } from "./writes.ts";
|
|
38
38
|
import { type BoardServer, createBoardServer } from "./transport/server.ts";
|
|
39
39
|
import { BOARD_CLIENT_JS } from "./view/client.ts";
|
|
40
40
|
import { type PageView, renderBoardPage } from "./view/page.ts";
|
|
@@ -57,11 +57,26 @@ export function randomPort(): number {
|
|
|
57
57
|
*/
|
|
58
58
|
export const IDLE_STOP_MS = 60_000;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* How long a page may stand with no tab ever attached.
|
|
62
|
+
*
|
|
63
|
+
* `/board` opens a browser, and a browser that does not open (a swallowed failure, a
|
|
64
|
+
* terminal on a machine with no default browser) would otherwise leave the port bound for
|
|
65
|
+
* the rest of the session. Two minutes is long enough for a person to finish reading the
|
|
66
|
+
* transcript and click the link `/board` printed.
|
|
67
|
+
*/
|
|
68
|
+
export const IDLE_FIRST_MS = 120_000;
|
|
69
|
+
|
|
60
70
|
/** What `/board status` prints. */
|
|
61
71
|
export interface BoardPageStatus {
|
|
62
72
|
running: boolean;
|
|
63
73
|
/** True when this session opened somebody else's page instead of serving one. */
|
|
64
74
|
attached?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* True when that page no longer answers: the note is there, the process behind it is
|
|
77
|
+
* not, and `/board stop` may drop it.
|
|
78
|
+
*/
|
|
79
|
+
stale?: boolean;
|
|
65
80
|
/** The name the page serves as: a seat, not the session that opened it. */
|
|
66
81
|
seat?: string;
|
|
67
82
|
port?: number;
|
|
@@ -90,6 +105,8 @@ export interface BoardPageDeps {
|
|
|
90
105
|
interval?: RepeatTimer;
|
|
91
106
|
/** Grace after the last tab leaves; defaults to {@link IDLE_STOP_MS}. */
|
|
92
107
|
idleMs?: number;
|
|
108
|
+
/** Grace for a page no tab ever attached to; defaults to {@link IDLE_FIRST_MS}. */
|
|
109
|
+
firstIdleMs?: number;
|
|
93
110
|
/** Whether a page another session left a note about is answering; a test drives it. */
|
|
94
111
|
probe?: ProbeFetch;
|
|
95
112
|
/** How the `board` section answers; a test drives it without a config file. */
|
|
@@ -100,12 +117,15 @@ export interface BoardPageDeps {
|
|
|
100
117
|
export interface BoardPage {
|
|
101
118
|
start: (ctx: ExtensionContext, view?: PageView) => Promise<PageStart>;
|
|
102
119
|
stop: (ctx: ExtensionContext) => Promise<boolean>;
|
|
103
|
-
status:
|
|
120
|
+
/** What `/board status` prints: served here, watched, watched and dead, or absent. */
|
|
121
|
+
status: () => Promise<BoardPageStatus>;
|
|
122
|
+
/** Drop the note of a page that has stopped answering. False when it still answers. */
|
|
123
|
+
dropStale: (ctx: ExtensionContext) => Promise<boolean>;
|
|
104
124
|
closeAll: () => Promise<void>;
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {}): BoardPage {
|
|
108
|
-
let running: { server: BoardServer; store: BoardStore;
|
|
128
|
+
let running: { server: BoardServer; store: BoardStore; token: string } | undefined;
|
|
109
129
|
/** The page another session serves, when this session opened that one instead. */
|
|
110
130
|
let attachedTo: PagePointer | undefined;
|
|
111
131
|
let sessionPort: number | undefined;
|
|
@@ -115,32 +135,60 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
115
135
|
|
|
116
136
|
const storeOf = async (ctx: ExtensionContext): Promise<BoardStore> => runtime.store(ctx.cwd);
|
|
117
137
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Close the page this session serves, and take its note with it.
|
|
140
|
+
*
|
|
141
|
+
* One close for both callers: `/board stop` has a context and clears the chip, and a
|
|
142
|
+
* clean `session_shutdown` has none. The note must leave either way — a note nobody
|
|
143
|
+
* answers makes every open tab reconnect forever and costs the next `/board` a probe
|
|
144
|
+
* and a takeover before it serves.
|
|
145
|
+
*/
|
|
146
|
+
const closeRunning = async (ctx?: ExtensionContext): Promise<boolean> => {
|
|
147
|
+
if (running === undefined) return false;
|
|
148
|
+
const { server, store, token } = running;
|
|
121
149
|
running = undefined;
|
|
122
150
|
await server.close();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
await clearPointer(store.dir, viewToken);
|
|
126
|
-
clearChip(ctx);
|
|
151
|
+
await clearPointer(store.dir, token);
|
|
152
|
+
if (ctx !== undefined) clearChip(ctx);
|
|
127
153
|
return true;
|
|
128
154
|
};
|
|
129
155
|
|
|
156
|
+
const stop = (ctx: ExtensionContext): Promise<boolean> => closeRunning(ctx);
|
|
157
|
+
|
|
130
158
|
const start = async (ctx: ExtensionContext, view: PageView = "board"): Promise<PageStart> => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
// `/board` is how the page is summoned, and a page whose tab was closed is one
|
|
160
|
+
// command away from being shown again — the same answer a viewer's `/board` gives.
|
|
161
|
+
if (running !== undefined) {
|
|
162
|
+
(deps.open ?? openInBrowser)(view === "board" ? running.server.url : `${running.server.url}&view=team`);
|
|
163
|
+
return describe(running);
|
|
164
|
+
}
|
|
165
|
+
const config = (deps.config ?? loadBoardConfig)(ctx.cwd);
|
|
166
|
+
const pinned = config.port > 0 ? config.port : undefined;
|
|
167
|
+
/** One refusal for every way a start can fail, named by what failed. */
|
|
168
|
+
const failed = (error: unknown): PageStart => {
|
|
169
|
+
const reason = failureReason(error, pinned);
|
|
170
|
+
ctx.ui.notify(`board: the page did not start (${reason})`, "error");
|
|
171
|
+
return { kind: "failed", reason };
|
|
172
|
+
};
|
|
173
|
+
// The board's own directory, then the page: both can fail on a path that exists and
|
|
174
|
+
// cannot be written, and a start that fails must leave no listener behind.
|
|
175
|
+
let store: BoardStore;
|
|
176
|
+
try {
|
|
177
|
+
store = await storeOf(ctx);
|
|
178
|
+
await store.ensure();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return failed(error);
|
|
181
|
+
}
|
|
134
182
|
const open = (url: string) => (deps.open ?? openInBrowser)(view === "board" ? url : `${url}&view=team`);
|
|
135
183
|
/** Watch the page another session serves: its note is the URL, its seat the name. */
|
|
136
184
|
const attach = (pointer: PagePointer): PageStart => {
|
|
137
185
|
attachedTo = pointer;
|
|
138
|
-
open(
|
|
186
|
+
open(urlOf(pointer));
|
|
139
187
|
return {
|
|
140
188
|
kind: "attached",
|
|
141
189
|
seat: pointer.seat.label,
|
|
142
190
|
port: pointer.port,
|
|
143
|
-
url:
|
|
191
|
+
url: urlOf(pointer),
|
|
144
192
|
where: store.where(),
|
|
145
193
|
};
|
|
146
194
|
};
|
|
@@ -151,24 +199,24 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
151
199
|
attachedTo = undefined;
|
|
152
200
|
const actor = () => seat;
|
|
153
201
|
const now = () => runtime.now();
|
|
154
|
-
// Read
|
|
155
|
-
|
|
156
|
-
const pinned = config.port > 0 ? config.port : undefined;
|
|
202
|
+
// Read above, so a pinned port names itself in a refusal; the port itself is
|
|
203
|
+
// remembered for the session so a closed and reopened page lands where it was.
|
|
157
204
|
const preferred = pinned ?? sessionPort ?? (deps.pickPort ?? randomPort)();
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
let server: BoardServer;
|
|
205
|
+
// Minted here rather than by the server: the note this session leaves has to carry it,
|
|
206
|
+
// and that is what a second session reads to attach instead of serving its own page.
|
|
207
|
+
const token = mintToken();
|
|
208
|
+
let server: BoardServer | undefined;
|
|
162
209
|
try {
|
|
163
210
|
server = await createBoardServer({
|
|
164
211
|
// A pinned port is tried alone: falling back would leave the bookmark pointing
|
|
165
212
|
// at a page that moved, which is the failure the setting exists to prevent.
|
|
166
213
|
ports: pinned === undefined ? [preferred, 0] : [pinned],
|
|
167
|
-
|
|
214
|
+
token,
|
|
168
215
|
...(deps.interval === undefined ? {} : { interval: deps.interval }),
|
|
169
216
|
idleMs: deps.idleMs ?? IDLE_STOP_MS,
|
|
170
|
-
|
|
171
|
-
|
|
217
|
+
firstIdleMs: deps.firstIdleMs ?? IDLE_FIRST_MS,
|
|
218
|
+
onIdle: (reason) => void stopIdle(ctx, reason),
|
|
219
|
+
page: async (view) => renderBoardPage({ view }),
|
|
172
220
|
script: BOARD_CLIENT_JS,
|
|
173
221
|
view: () =>
|
|
174
222
|
viewOf({
|
|
@@ -178,59 +226,65 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
178
226
|
now,
|
|
179
227
|
busDir: () => runtime.busDir(ctx.cwd),
|
|
180
228
|
}),
|
|
181
|
-
write: (request) => writeOf({ store, actor, now }, request),
|
|
182
229
|
});
|
|
230
|
+
// The note and the seat: written last, on the port the server actually bound, and only if no
|
|
231
|
+
// other session got there first: the loser of that race attaches to the page that
|
|
232
|
+
// won rather than serving a second page nobody can find. A note that answers
|
|
233
|
+
// nobody is taken over, so a page that died between its note and its server does not
|
|
234
|
+
// hold the name forever, and a note this build cannot read is named rather than
|
|
235
|
+
// blamed on a session that does not exist.
|
|
236
|
+
const note: PagePointer = {
|
|
237
|
+
port: server.port,
|
|
238
|
+
token,
|
|
239
|
+
seat: { id: seat.id, label: seat.label },
|
|
240
|
+
startedAt: runtime.now(),
|
|
241
|
+
};
|
|
242
|
+
const outcome = await settlePointer(store.dir, note, deps.probe);
|
|
243
|
+
if (outcome.kind === "attached") {
|
|
244
|
+
await server.close();
|
|
245
|
+
return attach(outcome.pointer);
|
|
246
|
+
}
|
|
247
|
+
if (outcome.kind === "written" && outcome.took !== undefined) {
|
|
248
|
+
const dead = outcome.took;
|
|
249
|
+
ctx.ui.notify(
|
|
250
|
+
`board: ${dead.seat.label} on :${dead.port} does not answer — open ${urlOf(dead)} · took its note over`,
|
|
251
|
+
"info",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (outcome.kind !== "written") {
|
|
255
|
+
await server.close();
|
|
256
|
+
const path = join(store.dir, PAGE_FILE);
|
|
257
|
+
const reason =
|
|
258
|
+
outcome.kind === "stale"
|
|
259
|
+
? `${path} names ${outcome.pointer.seat.label} on :${outcome.pointer.port}, and nothing answers — remove it to serve this board`
|
|
260
|
+
: outcome.kind === "busy"
|
|
261
|
+
? `${path} kept changing hands — run /board again`
|
|
262
|
+
: `${path} is there and cannot be read — remove it to serve this board`;
|
|
263
|
+
ctx.ui.notify(`board: the page did not start (${reason})`, "error");
|
|
264
|
+
return { kind: "failed", reason };
|
|
265
|
+
}
|
|
266
|
+
sessionPort = server.port;
|
|
267
|
+
running = { server, store, token };
|
|
268
|
+
setChip(ctx, server.port);
|
|
269
|
+
open(server.url);
|
|
270
|
+
return describe(running);
|
|
183
271
|
} catch (error) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
// The note is written last, on the port the server actually bound, and only if no
|
|
189
|
-
// other session got there first: the loser of that race becomes a viewer of the
|
|
190
|
-
// page that won rather than a second page nobody can find. A note that answers
|
|
191
|
-
// nobody is taken over, so a page that died between its note and its server does not
|
|
192
|
-
// hold the name forever, and a note this build cannot read is named rather than
|
|
193
|
-
// blamed on a session that does not exist.
|
|
194
|
-
const note: PagePointer = {
|
|
195
|
-
port: server.port,
|
|
196
|
-
viewToken,
|
|
197
|
-
seat: { id: seat.id, label: seat.label },
|
|
198
|
-
startedAt: runtime.now(),
|
|
199
|
-
};
|
|
200
|
-
const outcome = await settlePointer(store.dir, note, deps.probe);
|
|
201
|
-
if (outcome.kind === "attached") {
|
|
202
|
-
await server.close();
|
|
203
|
-
return attach(outcome.pointer);
|
|
272
|
+
// A bind that failed has no server to close; a seat that could not be written does,
|
|
273
|
+
// and leaving it bound would hold a port no note names — invisible until shutdown.
|
|
274
|
+
await server?.close();
|
|
275
|
+
return failed(error);
|
|
204
276
|
}
|
|
205
|
-
if (outcome.kind === "written" && outcome.took !== undefined) {
|
|
206
|
-
const dead = outcome.took;
|
|
207
|
-
ctx.ui.notify(
|
|
208
|
-
`board: ${dead.seat.label} on :${dead.port} does not answer — open ${viewUrlOf(dead)} · took its note over`,
|
|
209
|
-
"info",
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
if (outcome.kind !== "written") {
|
|
213
|
-
await server.close();
|
|
214
|
-
const path = join(store.dir, PAGE_FILE);
|
|
215
|
-
const reason =
|
|
216
|
-
outcome.kind === "stale"
|
|
217
|
-
? `${path} names ${outcome.pointer.seat.label} on :${outcome.pointer.port}, and nothing answers — remove it to serve this board`
|
|
218
|
-
: outcome.kind === "busy"
|
|
219
|
-
? `${path} kept changing hands — run /board again`
|
|
220
|
-
: `${path} is there and cannot be read — remove it to serve this board`;
|
|
221
|
-
ctx.ui.notify(`board: the page did not start (${reason})`, "error");
|
|
222
|
-
return { kind: "failed", reason };
|
|
223
|
-
}
|
|
224
|
-
sessionPort = server.port;
|
|
225
|
-
running = { server, store, viewToken };
|
|
226
|
-
setChip(ctx, server.port);
|
|
227
|
-
open(server.url);
|
|
228
|
-
return describe(running);
|
|
229
277
|
};
|
|
230
278
|
|
|
231
|
-
/** The idle grace closes the page and says
|
|
232
|
-
const stopIdle = async (ctx: ExtensionContext): Promise<void> => {
|
|
233
|
-
if (await stop(ctx))
|
|
279
|
+
/** The idle grace closes the page and says which grace it was, once. */
|
|
280
|
+
const stopIdle = async (ctx: ExtensionContext, reason: IdleReason): Promise<void> => {
|
|
281
|
+
if (!(await stop(ctx))) return;
|
|
282
|
+
ctx.ui.notify(
|
|
283
|
+
reason === "first"
|
|
284
|
+
? "board: stopped — no tab ever opened; /board starts it again"
|
|
285
|
+
: "board: stopped — the last page was closed",
|
|
286
|
+
"info",
|
|
287
|
+
);
|
|
234
288
|
};
|
|
235
289
|
|
|
236
290
|
const describe = (state: { server: BoardServer; store: BoardStore }): PageStart => ({
|
|
@@ -241,9 +295,16 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
241
295
|
where: state.store.where(),
|
|
242
296
|
});
|
|
243
297
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
298
|
+
/**
|
|
299
|
+
* What `/board status` answers, wherever the page is: served here, watched, watched and
|
|
300
|
+
* dead, or absent.
|
|
301
|
+
*
|
|
302
|
+
* A watched page is probed, because the note saying it exists is not the same thing as
|
|
303
|
+
* it answering: after the lead's session ends, its note stays and the reader would
|
|
304
|
+
* otherwise be told about a port nobody holds.
|
|
305
|
+
*/
|
|
306
|
+
const statusOf = async (): Promise<BoardPageStatus> => {
|
|
307
|
+
if (running !== undefined) {
|
|
247
308
|
return {
|
|
248
309
|
running: true,
|
|
249
310
|
seat: seat.label,
|
|
@@ -253,27 +314,42 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
253
314
|
watchers: running.server.watchers(),
|
|
254
315
|
};
|
|
255
316
|
}
|
|
256
|
-
if (attachedTo
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
317
|
+
if (attachedTo === undefined) return { running: false };
|
|
318
|
+
const answers = await probePage(attachedTo, deps.probe);
|
|
319
|
+
return {
|
|
320
|
+
running: false,
|
|
321
|
+
attached: true,
|
|
322
|
+
...(answers ? {} : { stale: true }),
|
|
323
|
+
seat: attachedTo.seat.label,
|
|
324
|
+
port: attachedTo.port,
|
|
325
|
+
url: urlOf(attachedTo),
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Drop the note of a page that has stopped answering.
|
|
331
|
+
*
|
|
332
|
+
* Only a note this session watched, and only while it answers nobody: a page that came
|
|
333
|
+
* back between the status line and this call keeps its note, and `clearPointer`'s token
|
|
334
|
+
* check makes that true even if a new page wrote one in between.
|
|
335
|
+
*/
|
|
336
|
+
const dropStale = async (ctx: ExtensionContext): Promise<boolean> => {
|
|
337
|
+
const pointer = attachedTo;
|
|
338
|
+
if (pointer === undefined) return false;
|
|
339
|
+
if (await probePage(pointer, deps.probe)) return false;
|
|
340
|
+
const store = await storeOf(ctx);
|
|
341
|
+
if (!(await clearPointer(store.dir, pointer.token))) return false;
|
|
342
|
+
attachedTo = undefined;
|
|
343
|
+
return true;
|
|
266
344
|
};
|
|
267
345
|
|
|
268
346
|
return {
|
|
269
347
|
start,
|
|
270
348
|
stop,
|
|
271
349
|
status: statusOf,
|
|
350
|
+
dropStale,
|
|
272
351
|
closeAll: async () => {
|
|
273
|
-
|
|
274
|
-
const { server } = running;
|
|
275
|
-
running = undefined;
|
|
276
|
-
await server.close();
|
|
352
|
+
await closeRunning();
|
|
277
353
|
},
|
|
278
354
|
};
|
|
279
355
|
}
|
|
@@ -58,6 +58,15 @@ export function resultInput(details: BoardDetails): TailResultInput<Card> | unde
|
|
|
58
58
|
*/
|
|
59
59
|
export function collapsedLine(theme: Theme, details: BoardDetails): string {
|
|
60
60
|
const me = paint(theme, "faint", sanitizeLine(details.me.label));
|
|
61
|
+
const archived = details.archived;
|
|
62
|
+
if (archived !== undefined && archived.length > 0) {
|
|
63
|
+
const first = archived[0];
|
|
64
|
+
const label =
|
|
65
|
+
archived.length === 1 && first !== undefined
|
|
66
|
+
? `${cardRef(first)} archived`
|
|
67
|
+
: `${plural(archived.length, "card")} archived`;
|
|
68
|
+
return `${paint(theme, "label", label)} ${SEPARATORS.item} ${me}`;
|
|
69
|
+
}
|
|
61
70
|
if (details.dropped)
|
|
62
71
|
return `${paint(theme, "label", `${cardRef(details.dropped)} dropped`)} ${SEPARATORS.item} ${me}`;
|
|
63
72
|
const card = details.card;
|