@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 +19 -12
- package/README.zh-CN.md +15 -9
- package/docs/extensions/README.md +2 -1
- package/docs/extensions/board.md +329 -151
- package/docs/extensions/bytefly.md +427 -273
- package/docs/extensions/file-browse.md +46 -44
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +86 -24
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +56 -28
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +48 -17
- package/extensions/board/format.ts +107 -9
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +202 -156
- package/extensions/board/result.ts +10 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +31 -10
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +7 -5
- package/extensions/board/tools.ts +211 -34
- package/extensions/board/transport/router.ts +27 -110
- package/extensions/board/transport/server.ts +76 -56
- package/extensions/board/types.ts +57 -0
- package/extensions/board/view/client.ts +24 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +6 -178
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +60 -89
- package/extensions/board/view/render.ts +228 -200
- package/extensions/board/view/styles.ts +72 -49
- 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 -11
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +93 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/header/component.ts +4 -20
- 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/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- 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/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -6
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +78 -46
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +23 -26
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +27 -22
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/runtime.ts +4 -4
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +88 -18
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +24 -2
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +43 -4
- 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/tools.ts +2 -2
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +160 -0
- package/extensions/plan/web-chrome.ts +7 -5
- package/extensions/plan/web-client.ts +271 -325
- package/extensions/plan/web-collab.ts +7 -7
- package/extensions/plan/web-comments.ts +57 -16
- package/extensions/plan/web-composer.ts +91 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +258 -0
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +212 -0
- package/extensions/plan/web-page.ts +59 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +160 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +201 -0
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- 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/tools.ts +5 -4
- 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 +44 -9
- 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/todo/tui.ts +21 -3
- 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 +9 -7
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +111 -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/session.ts +84 -18
- package/lib/board/store.ts +268 -55
- package/lib/board/view.ts +114 -24
- package/lib/bus-request.ts +83 -0
- package/lib/format.ts +22 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -43
- package/lib/http/server.ts +3 -3
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +20 -13
- package/lib/policy.ts +14 -1
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-roster.ts +74 -3
- package/lib/session-tool.ts +37 -21
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +3 -6
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +149 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +106 -2
- package/lib/web/names.ts +42 -0
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
- 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/extensions/board/page.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The page's lifecycle: one
|
|
2
|
+
* The page's lifecycle: one mount per session, started by `/board` and given back by
|
|
3
3
|
* `/board stop`, by the idle grace once the last tab has been gone for a minute, or by
|
|
4
4
|
* `session_shutdown`.
|
|
5
5
|
*
|
|
6
|
-
* Only the lifecycle lives here — the state machine, the
|
|
7
|
-
*
|
|
8
|
-
* *
|
|
9
|
-
* knows nothing of the page's markup, and
|
|
6
|
+
* Only the lifecycle lives here — the state machine, the prefix it holds, the note that
|
|
7
|
+
* names it, and the three closes. The session's port belongs to `serve`, so nothing here
|
|
8
|
+
* picks one. What the page *reads* is `snapshot.ts`, and it writes nothing: how a request
|
|
9
|
+
* reaches the snapshot is `transport/`, the router knows nothing of the page's markup, and
|
|
10
|
+
* this is still the one place they meet.
|
|
10
11
|
*
|
|
11
12
|
* All state lives in this closure, not at module scope: Pi loads each extension with an
|
|
12
13
|
* isolated module cache, so a module variable would be one copy per extension.
|
|
@@ -15,11 +16,10 @@
|
|
|
15
16
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
17
|
import type { BoardStore } from "../../lib/board/store.ts";
|
|
17
18
|
import { openInBrowser } from "../../lib/browser.ts";
|
|
18
|
-
import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
19
|
-
import { paint } from "../../lib/tui/tokens.ts";
|
|
20
19
|
import type { RepeatTimer } from "../../lib/timer.ts";
|
|
21
20
|
import type { BoardSession } from "../../lib/board/session.ts";
|
|
22
21
|
import {
|
|
22
|
+
BOARD_BASE,
|
|
23
23
|
clearPointer,
|
|
24
24
|
PAGE_FILE,
|
|
25
25
|
type PagePointer,
|
|
@@ -27,27 +27,18 @@ import {
|
|
|
27
27
|
probePage,
|
|
28
28
|
readPointer,
|
|
29
29
|
settlePointer,
|
|
30
|
-
|
|
30
|
+
urlOf,
|
|
31
31
|
} from "../../lib/board/page-pointer.ts";
|
|
32
|
-
import { mintToken } from "../../lib/http/index.ts";
|
|
32
|
+
import { mintToken, type MountSource } from "../../lib/http/index.ts";
|
|
33
|
+
import { messageOf } from "../../lib/errors.ts";
|
|
34
|
+
import type { IdleReason } from "../../lib/http/sink.ts";
|
|
33
35
|
import { join } from "node:path";
|
|
34
|
-
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";
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* A free port in the range the other loopback pages use, so a session's pages cluster.
|
|
44
|
-
* Only the unpinned page draws one: `board.port` in `bytefly.json` states the port a
|
|
45
|
-
* bookmarked URL keeps.
|
|
46
|
-
*/
|
|
47
|
-
export function randomPort(): number {
|
|
48
|
-
return 4780 + Math.floor(Math.random() * 1000);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
42
|
/**
|
|
52
43
|
* How long the page waits for a tab after the last one leaves.
|
|
53
44
|
*
|
|
@@ -57,15 +48,32 @@ export function randomPort(): number {
|
|
|
57
48
|
*/
|
|
58
49
|
export const IDLE_STOP_MS = 60_000;
|
|
59
50
|
|
|
51
|
+
/**
|
|
52
|
+
* How long a page may stand with no tab ever attached.
|
|
53
|
+
*
|
|
54
|
+
* `/board` opens a browser, and a browser that does not open (a swallowed failure, a
|
|
55
|
+
* terminal on a machine with no default browser) would otherwise leave the port bound for
|
|
56
|
+
* the rest of the session. Two minutes is long enough for a person to finish reading the
|
|
57
|
+
* transcript and click the link `/board` printed.
|
|
58
|
+
*/
|
|
59
|
+
export const IDLE_FIRST_MS = 120_000;
|
|
60
|
+
|
|
60
61
|
/** What `/board status` prints. */
|
|
61
62
|
export interface BoardPageStatus {
|
|
62
63
|
running: boolean;
|
|
63
64
|
/** True when this session opened somebody else's page instead of serving one. */
|
|
64
65
|
attached?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* True when that page no longer answers: the note is there, the process behind it is
|
|
68
|
+
* not, and `/board stop` may drop it.
|
|
69
|
+
*/
|
|
70
|
+
stale?: boolean;
|
|
65
71
|
/** The name the page serves as: a seat, not the session that opened it. */
|
|
66
72
|
seat?: string;
|
|
67
73
|
port?: number;
|
|
68
74
|
url?: string;
|
|
75
|
+
/** The repository's name, so `status` says which board the page is. */
|
|
76
|
+
repo?: string;
|
|
69
77
|
where?: string;
|
|
70
78
|
/** How many tabs are attached, while this session serves the page. */
|
|
71
79
|
watchers?: number;
|
|
@@ -79,68 +87,104 @@ export interface BoardPageStatus {
|
|
|
79
87
|
* page* is a third answer a `running: false` could not express.
|
|
80
88
|
*/
|
|
81
89
|
export type PageStart =
|
|
82
|
-
| { kind: "served"; seat: string; port: number; url: string; where: string }
|
|
83
|
-
| { kind: "attached"; seat: string; port: number; url: string; where: string }
|
|
90
|
+
| { kind: "served"; seat: string; port: number; url: string; repo: string; where: string }
|
|
91
|
+
| { kind: "attached"; seat: string; port: number; url: string; repo: string; where: string }
|
|
84
92
|
| { kind: "failed"; reason: string };
|
|
85
93
|
|
|
86
94
|
/** Test seams and the one place a caller may redirect the browser. */
|
|
87
95
|
export interface BoardPageDeps {
|
|
88
96
|
open?: (url: string) => void;
|
|
89
|
-
|
|
97
|
+
/** Where the prefix comes from: the session's server, or a suite's own. */
|
|
98
|
+
mount: MountSource;
|
|
99
|
+
/** The Pi session this page serves as, so the prefix lands on its socket. */
|
|
100
|
+
session: () => string;
|
|
90
101
|
interval?: RepeatTimer;
|
|
91
102
|
/** Grace after the last tab leaves; defaults to {@link IDLE_STOP_MS}. */
|
|
92
103
|
idleMs?: number;
|
|
104
|
+
/** Grace for a page no tab ever attached to; defaults to {@link IDLE_FIRST_MS}. */
|
|
105
|
+
firstIdleMs?: number;
|
|
93
106
|
/** Whether a page another session left a note about is answering; a test drives it. */
|
|
94
107
|
probe?: ProbeFetch;
|
|
95
|
-
/** How the `board` section answers; a test drives it without a config file. */
|
|
96
|
-
config?: (cwd: string) => BoardConfig;
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
/** The page as `/board` and `session_shutdown` drive it. */
|
|
100
111
|
export interface BoardPage {
|
|
101
112
|
start: (ctx: ExtensionContext, view?: PageView) => Promise<PageStart>;
|
|
102
|
-
|
|
103
|
-
|
|
113
|
+
/** Give the prefix back; the session's server closes with its last mount. */
|
|
114
|
+
stop: () => Promise<boolean>;
|
|
115
|
+
/** What `/board status` prints: served here, watched, watched and dead, or absent. */
|
|
116
|
+
status: () => Promise<BoardPageStatus>;
|
|
117
|
+
/** Drop the note of a page that has stopped answering. False when it still answers. */
|
|
118
|
+
dropStale: (ctx: ExtensionContext) => Promise<boolean>;
|
|
104
119
|
closeAll: () => Promise<void>;
|
|
105
120
|
}
|
|
106
121
|
|
|
107
|
-
export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps
|
|
108
|
-
let running: { server: BoardServer; store: BoardStore;
|
|
122
|
+
export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps): BoardPage {
|
|
123
|
+
let running: { server: BoardServer; store: BoardStore; token: string } | undefined;
|
|
109
124
|
/** The page another session serves, when this session opened that one instead. */
|
|
110
125
|
let attachedTo: PagePointer | undefined;
|
|
111
|
-
let sessionPort: number | undefined;
|
|
112
126
|
// The page is a seat, not a session: one name and one id for its whole life, so a card
|
|
113
127
|
// a browser claimed is still the seat's after the session that served it ends.
|
|
114
128
|
const seat = pageActor();
|
|
115
129
|
|
|
116
130
|
const storeOf = async (ctx: ExtensionContext): Promise<BoardStore> => runtime.store(ctx.cwd);
|
|
117
131
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Close the page this session serves, and take its note with it.
|
|
134
|
+
*
|
|
135
|
+
* One close for both callers: `/board stop` has a context and clears the status line, and
|
|
136
|
+
* a clean `session_shutdown` has none. The note must leave either way — a note nobody
|
|
137
|
+
* answers makes every open tab reconnect forever and costs the next `/board` a probe
|
|
138
|
+
* and a takeover before it serves.
|
|
139
|
+
*/
|
|
140
|
+
const closeRunning = async (): Promise<boolean> => {
|
|
141
|
+
if (running === undefined) return false;
|
|
142
|
+
const { server, store, token } = running;
|
|
121
143
|
running = undefined;
|
|
122
144
|
await server.close();
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
145
|
+
await clearPointer(store.dir, token);
|
|
146
|
+
// A page that closed is no seat: the session's next card is written as itself. This is
|
|
147
|
+
// the one place all three closes pass — `/board stop`, the idle grace, and shutdown — so
|
|
148
|
+
// a page nobody is looking at cannot leave the session serving in name only.
|
|
149
|
+
runtime.serve(undefined);
|
|
127
150
|
return true;
|
|
128
151
|
};
|
|
129
152
|
|
|
153
|
+
const stop = (): Promise<boolean> => closeRunning();
|
|
154
|
+
|
|
130
155
|
const start = async (ctx: ExtensionContext, view: PageView = "board"): Promise<PageStart> => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
// `/board` is how the page is summoned, and a page whose tab was closed is one
|
|
157
|
+
// command away from being shown again — the same answer a viewer's `/board` gives.
|
|
158
|
+
if (running !== undefined) {
|
|
159
|
+
(deps.open ?? openInBrowser)(view === "board" ? running.server.url : `${running.server.url}&view=team`);
|
|
160
|
+
return describe(running);
|
|
161
|
+
}
|
|
162
|
+
/** One refusal for every way a start can fail, named by what failed. */
|
|
163
|
+
const failed = (error: unknown): PageStart => {
|
|
164
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
165
|
+
ctx.ui.notify(`board: the page did not start (${reason})`, "error");
|
|
166
|
+
return { kind: "failed", reason };
|
|
167
|
+
};
|
|
168
|
+
// The board's own directory, then the page: both can fail on a path that exists and
|
|
169
|
+
// cannot be written, and a start that fails must leave no listener behind.
|
|
170
|
+
let store: BoardStore;
|
|
171
|
+
try {
|
|
172
|
+
store = await storeOf(ctx);
|
|
173
|
+
await store.ensure();
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return failed(error);
|
|
176
|
+
}
|
|
134
177
|
const open = (url: string) => (deps.open ?? openInBrowser)(view === "board" ? url : `${url}&view=team`);
|
|
135
178
|
/** Watch the page another session serves: its note is the URL, its seat the name. */
|
|
136
179
|
const attach = (pointer: PagePointer): PageStart => {
|
|
137
180
|
attachedTo = pointer;
|
|
138
|
-
open(
|
|
181
|
+
open(urlOf(pointer));
|
|
139
182
|
return {
|
|
140
183
|
kind: "attached",
|
|
141
184
|
seat: pointer.seat.label,
|
|
142
185
|
port: pointer.port,
|
|
143
|
-
url:
|
|
186
|
+
url: urlOf(pointer),
|
|
187
|
+
repo: store.name(),
|
|
144
188
|
where: store.where(),
|
|
145
189
|
};
|
|
146
190
|
};
|
|
@@ -151,86 +195,97 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
151
195
|
attachedTo = undefined;
|
|
152
196
|
const actor = () => seat;
|
|
153
197
|
const now = () => runtime.now();
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
198
|
+
// Minted here rather than by the server: the note this session leaves has to carry it,
|
|
199
|
+
// and that is what a second session reads to attach instead of serving its own page.
|
|
200
|
+
const token = mintToken();
|
|
201
|
+
/** Whether this page has already said it cannot read the board. */
|
|
202
|
+
let blind = false;
|
|
203
|
+
// A frame the page cannot build is a dropped frame, not a crash: the ticker asks again
|
|
204
|
+
// in a second. Said once, because a failure that persists would otherwise become one
|
|
205
|
+
// notice per tick — and unsaid, a page that stopped following the board looks frozen.
|
|
206
|
+
const warnBlind = (error: unknown): void => {
|
|
207
|
+
if (blind) return;
|
|
208
|
+
blind = true;
|
|
209
|
+
ctx.ui.notify(`board: the page cannot read the board (${messageOf(error)}) — it will keep trying`, "warning");
|
|
210
|
+
};
|
|
162
211
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
viewToken,
|
|
212
|
+
const started = await createBoardServer({
|
|
213
|
+
mount: deps.mount,
|
|
214
|
+
session: deps.session(),
|
|
215
|
+
token,
|
|
168
216
|
...(deps.interval === undefined ? {} : { interval: deps.interval }),
|
|
217
|
+
onError: warnBlind,
|
|
169
218
|
idleMs: deps.idleMs ?? IDLE_STOP_MS,
|
|
170
|
-
|
|
171
|
-
|
|
219
|
+
firstIdleMs: deps.firstIdleMs ?? IDLE_FIRST_MS,
|
|
220
|
+
onIdle: (reason) => void stopIdle(ctx, reason),
|
|
221
|
+
page: async (view) => renderBoardPage({ view, base: BOARD_BASE }),
|
|
172
222
|
script: BOARD_CLIENT_JS,
|
|
173
223
|
view: () =>
|
|
174
224
|
viewOf({
|
|
175
225
|
store,
|
|
176
226
|
actor,
|
|
177
|
-
session: () => runtime.who(ctx, ctx.cwd),
|
|
178
227
|
now,
|
|
179
228
|
busDir: () => runtime.busDir(ctx.cwd),
|
|
180
229
|
}),
|
|
181
|
-
write: (request) => writeOf({ store, actor, now }, request),
|
|
182
230
|
});
|
|
231
|
+
if (started.kind === "refused") {
|
|
232
|
+
ctx.ui.notify(`board: the page did not start (${started.reason})`, "error");
|
|
233
|
+
return { kind: "failed", reason: started.reason };
|
|
234
|
+
}
|
|
235
|
+
const server = started.server;
|
|
236
|
+
// The note and the seat: written last, on the port the server actually bound, and only if no
|
|
237
|
+
// other session got there first: the loser of that race attaches to the page that
|
|
238
|
+
// won rather than serving a second page nobody can find. A note that answers
|
|
239
|
+
// nobody is taken over, so a page that died between its note and its server does not
|
|
240
|
+
// hold the name forever, and a note this build cannot read is named rather than
|
|
241
|
+
// blamed on a session that does not exist.
|
|
242
|
+
const note: PagePointer = {
|
|
243
|
+
port: server.port,
|
|
244
|
+
token,
|
|
245
|
+
seat: { id: seat.id, label: seat.label },
|
|
246
|
+
startedAt: runtime.now(),
|
|
247
|
+
};
|
|
248
|
+
const outcome = await settlePointer(store.dir, note, deps.probe);
|
|
249
|
+
if (outcome.kind === "attached") {
|
|
250
|
+
await server.close();
|
|
251
|
+
return attach(outcome.pointer);
|
|
252
|
+
}
|
|
253
|
+
if (outcome.kind === "written" && outcome.took !== undefined) {
|
|
254
|
+
const dead = outcome.took;
|
|
255
|
+
ctx.ui.notify(
|
|
256
|
+
`board: ${dead.seat.label} on :${dead.port} does not answer — open ${urlOf(dead)} · took its note over`,
|
|
257
|
+
"info",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (outcome.kind !== "written") {
|
|
261
|
+
await server.close();
|
|
262
|
+
const path = join(store.dir, PAGE_FILE);
|
|
263
|
+
const reason =
|
|
264
|
+
outcome.kind === "stale"
|
|
265
|
+
? `${path} names ${outcome.pointer.seat.label} on :${outcome.pointer.port}, and nothing answers — remove it to serve this board`
|
|
266
|
+
: outcome.kind === "busy"
|
|
267
|
+
? `${path} kept changing hands — run /board again`
|
|
268
|
+
: `${path} is there and cannot be read — remove it to serve this board`;
|
|
269
|
+
ctx.ui.notify(`board: the page did not start (${reason})`, "error");
|
|
270
|
+
return { kind: "failed", reason };
|
|
271
|
+
}
|
|
272
|
+
running = { server, store, token };
|
|
273
|
+
open(server.url);
|
|
274
|
+
return describe(running);
|
|
183
275
|
} catch (error) {
|
|
184
|
-
|
|
185
|
-
ctx.ui.notify(`board: the page did not start (${reason})`, "error");
|
|
186
|
-
return { kind: "failed", reason };
|
|
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);
|
|
276
|
+
return failed(error);
|
|
204
277
|
}
|
|
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
278
|
};
|
|
230
279
|
|
|
231
|
-
/** The idle grace closes the page and says
|
|
232
|
-
const stopIdle = async (ctx: ExtensionContext): Promise<void> => {
|
|
233
|
-
if (await stop(
|
|
280
|
+
/** The idle grace closes the page and says which grace it was, once. */
|
|
281
|
+
const stopIdle = async (ctx: ExtensionContext, reason: IdleReason): Promise<void> => {
|
|
282
|
+
if (!(await stop())) return;
|
|
283
|
+
ctx.ui.notify(
|
|
284
|
+
reason === "first"
|
|
285
|
+
? "board: stopped — no tab ever opened; /board starts it again"
|
|
286
|
+
: "board: stopped — the last page was closed",
|
|
287
|
+
"info",
|
|
288
|
+
);
|
|
234
289
|
};
|
|
235
290
|
|
|
236
291
|
const describe = (state: { server: BoardServer; store: BoardStore }): PageStart => ({
|
|
@@ -238,75 +293,66 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
238
293
|
seat: seat.label,
|
|
239
294
|
port: state.server.port,
|
|
240
295
|
url: state.server.url,
|
|
296
|
+
repo: state.store.name(),
|
|
241
297
|
where: state.store.where(),
|
|
242
298
|
});
|
|
243
299
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
300
|
+
/**
|
|
301
|
+
* What `/board status` answers, wherever the page is: served here, watched, watched and
|
|
302
|
+
* dead, or absent.
|
|
303
|
+
*
|
|
304
|
+
* A watched page is probed, because the note saying it exists is not the same thing as
|
|
305
|
+
* it answering: after the lead's session ends, its note stays and the reader would
|
|
306
|
+
* otherwise be told about a port nobody holds.
|
|
307
|
+
*/
|
|
308
|
+
const statusOf = async (): Promise<BoardPageStatus> => {
|
|
309
|
+
if (running !== undefined) {
|
|
247
310
|
return {
|
|
248
311
|
running: true,
|
|
249
312
|
seat: seat.label,
|
|
250
313
|
port: running.server.port,
|
|
251
314
|
url: running.server.url,
|
|
315
|
+
repo: running.store.name(),
|
|
252
316
|
where: running.store.where(),
|
|
253
317
|
watchers: running.server.watchers(),
|
|
254
318
|
};
|
|
255
319
|
}
|
|
256
|
-
if (attachedTo
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
320
|
+
if (attachedTo === undefined) return { running: false };
|
|
321
|
+
const answers = await probePage(attachedTo, deps.probe);
|
|
322
|
+
return {
|
|
323
|
+
running: false,
|
|
324
|
+
attached: true,
|
|
325
|
+
...(answers ? {} : { stale: true }),
|
|
326
|
+
seat: attachedTo.seat.label,
|
|
327
|
+
port: attachedTo.port,
|
|
328
|
+
url: urlOf(attachedTo),
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Drop the note of a page that has stopped answering.
|
|
334
|
+
*
|
|
335
|
+
* Only a note this session watched, and only while it answers nobody: a page that came
|
|
336
|
+
* back between the status line and this call keeps its note, and `clearPointer`'s token
|
|
337
|
+
* check makes that true even if a new page wrote one in between.
|
|
338
|
+
*/
|
|
339
|
+
const dropStale = async (ctx: ExtensionContext): Promise<boolean> => {
|
|
340
|
+
const pointer = attachedTo;
|
|
341
|
+
if (pointer === undefined) return false;
|
|
342
|
+
if (await probePage(pointer, deps.probe)) return false;
|
|
343
|
+
const store = await storeOf(ctx);
|
|
344
|
+
if (!(await clearPointer(store.dir, pointer.token))) return false;
|
|
345
|
+
attachedTo = undefined;
|
|
346
|
+
return true;
|
|
266
347
|
};
|
|
267
348
|
|
|
268
349
|
return {
|
|
269
350
|
start,
|
|
270
351
|
stop,
|
|
271
352
|
status: statusOf,
|
|
353
|
+
dropStale,
|
|
272
354
|
closeAll: async () => {
|
|
273
|
-
|
|
274
|
-
const { server } = running;
|
|
275
|
-
running = undefined;
|
|
276
|
-
await server.close();
|
|
355
|
+
await closeRunning();
|
|
277
356
|
},
|
|
278
357
|
};
|
|
279
358
|
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Why a start failed, naming the setting when the pinned port was the reason: a
|
|
283
|
-
* bind error's own text says the address, not the key that chose it.
|
|
284
|
-
*/
|
|
285
|
-
function failureReason(error: unknown, pinned: number | undefined): string {
|
|
286
|
-
// The bind path rejects with the `listen` error, so `code` is there to read.
|
|
287
|
-
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
288
|
-
if (pinned !== undefined && code === "EADDRINUSE") {
|
|
289
|
-
return `port ${pinned} is in use — change "port" in the bytefly.json board section, or stop what answers on it`;
|
|
290
|
-
}
|
|
291
|
-
return error instanceof Error ? error.message : String(error);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** The chip naming the port, cleared with an empty text. */
|
|
295
|
-
function setChip(ctx: ExtensionContext, port: number): void {
|
|
296
|
-
try {
|
|
297
|
-
ctx.ui.setStatus(
|
|
298
|
-
STATUS_KEYS.board,
|
|
299
|
-
`${paint(ctx.ui.theme, "ok", GLYPHS.board)} ${paint(ctx.ui.theme, "accent", String(port))}`,
|
|
300
|
-
);
|
|
301
|
-
} catch {
|
|
302
|
-
// A host without a status line is not a reason to keep the page from starting.
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function clearChip(ctx: ExtensionContext): void {
|
|
307
|
-
try {
|
|
308
|
-
ctx.ui.setStatus(STATUS_KEYS.board, undefined);
|
|
309
|
-
} catch {
|
|
310
|
-
// Same as above: nothing to clear.
|
|
311
|
-
}
|
|
312
|
-
}
|
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
12
11
|
import { type Card, cardRef } from "../../lib/board/card.ts";
|
|
13
12
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
14
13
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
15
|
-
import {
|
|
14
|
+
import type { TailResultInput, TailSection } from "../../lib/tui/tool.ts";
|
|
16
15
|
import { plural } from "../../lib/board/round-text.ts";
|
|
17
16
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
18
17
|
import { boardRowLine } from "./rows.ts";
|
|
@@ -58,6 +57,15 @@ export function resultInput(details: BoardDetails): TailResultInput<Card> | unde
|
|
|
58
57
|
*/
|
|
59
58
|
export function collapsedLine(theme: Theme, details: BoardDetails): string {
|
|
60
59
|
const me = paint(theme, "faint", sanitizeLine(details.me.label));
|
|
60
|
+
const archived = details.archived;
|
|
61
|
+
if (archived !== undefined && archived.length > 0) {
|
|
62
|
+
const first = archived[0];
|
|
63
|
+
const label =
|
|
64
|
+
archived.length === 1 && first !== undefined
|
|
65
|
+
? `${cardRef(first)} archived`
|
|
66
|
+
: `${plural(archived.length, "card")} archived`;
|
|
67
|
+
return `${paint(theme, "label", label)} ${SEPARATORS.item} ${me}`;
|
|
68
|
+
}
|
|
61
69
|
if (details.dropped)
|
|
62
70
|
return `${paint(theme, "label", `${cardRef(details.dropped)} dropped`)} ${SEPARATORS.item} ${me}`;
|
|
63
71
|
const card = details.card;
|
|
@@ -75,25 +83,3 @@ function cardLine(theme: Theme, card: Card): string {
|
|
|
75
83
|
const lane = paint(theme, "accent", card.status);
|
|
76
84
|
return `${id} ${lane} ${SEPARATORS.item} ${paint(theme, "label", sanitizeLine(card.title))}`;
|
|
77
85
|
}
|
|
78
|
-
|
|
79
|
-
/** The expanded block, as a component the transcript can reuse across renders. */
|
|
80
|
-
export class BoardResult implements Component {
|
|
81
|
-
private readonly view: TailResult<Card>;
|
|
82
|
-
|
|
83
|
-
constructor(input: TailResultInput<Card>, theme: Theme) {
|
|
84
|
-
this.view = new TailResult(input, theme);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Update in place so the transcript reuses this component. */
|
|
88
|
-
setInput(input: TailResultInput<Card>, theme: Theme): void {
|
|
89
|
-
this.view.setInput(input, theme);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
invalidate(): void {
|
|
93
|
-
this.view.invalidate();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
render(width: number): string[] {
|
|
97
|
-
return this.view.render(width);
|
|
98
|
-
}
|
|
99
|
-
}
|
package/extensions/board/rows.ts
CHANGED
|
@@ -35,7 +35,7 @@ function stateOf(card: Card): string[] {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** One card: the id and title, with its state trailing. */
|
|
38
|
-
|
|
38
|
+
function cardRowLine(card: Card, width: number, theme: Theme, options: { self?: boolean } = {}): string {
|
|
39
39
|
return listRow(theme, width, {
|
|
40
40
|
marker: false,
|
|
41
41
|
label: `${cardRef(card.id)} ${sanitizeLine(card.title)}`,
|
|
@@ -8,31 +8,40 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Type } from "typebox";
|
|
11
|
-
import { CARD_STATUSES, PRIORITIES } from "../../lib/board/card.ts";
|
|
11
|
+
import { CARD_STATUSES, MAX_NOTE, PRIORITIES } from "../../lib/board/card.ts";
|
|
12
12
|
import { BOARD_ACTIONS } from "./types.ts";
|
|
13
13
|
|
|
14
14
|
export const BoardParams = Type.Object({
|
|
15
15
|
action: Type.Union(
|
|
16
16
|
BOARD_ACTIONS.map((action) => Type.Literal(action)),
|
|
17
17
|
{
|
|
18
|
+
// The verbs and their meanings live in the tool description, built from
|
|
19
|
+
// `BOARD_ACTION_DOCS`; this says only what a caller needs to choose one.
|
|
18
20
|
description:
|
|
19
|
-
"`list` the board
|
|
20
|
-
"`claim` one for this session, `release` it, `reclaim` it from whoever holds it, `move` it to " +
|
|
21
|
-
"another lane, `note` it, `plan` it (record the plan file being executed), `edit` what it says " +
|
|
22
|
-
"(title, paths, priority, what it waits for), or `drop` it.",
|
|
21
|
+
"`list` reads the board, or one card when `card` names it; every other verb changes it. The tool description says what each one does.",
|
|
23
22
|
},
|
|
24
23
|
),
|
|
25
24
|
card: Type.Optional(
|
|
26
|
-
Type.String({
|
|
25
|
+
Type.String({
|
|
26
|
+
description:
|
|
27
|
+
'The card id for one card, e.g. "4" or "#4". `list` reads that card; every other action but `add` acts on it.',
|
|
28
|
+
}),
|
|
27
29
|
),
|
|
28
30
|
status: Type.Optional(
|
|
29
31
|
Type.Union(
|
|
30
32
|
CARD_STATUSES.map((status) => Type.Literal(status)),
|
|
31
33
|
{
|
|
32
|
-
description:
|
|
34
|
+
description:
|
|
35
|
+
"`list`: show only this lane, and not skip the `done` cards. `move`: the lane to move to (not `todo` — use `release`).",
|
|
33
36
|
},
|
|
34
37
|
),
|
|
35
38
|
),
|
|
39
|
+
blockedBy: Type.Optional(
|
|
40
|
+
Type.String({
|
|
41
|
+
description:
|
|
42
|
+
"`move` to `blocked`: what the card waits on — the decision a person has to make, one line, at most 200 characters.",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
36
45
|
title: Type.Optional(Type.String({ description: "`add` and `edit`: the feature's outcome, one line." })),
|
|
37
46
|
paths: Type.Optional(
|
|
38
47
|
Type.Array(Type.String(), {
|
|
@@ -40,8 +49,14 @@ export const BoardParams = Type.Object({
|
|
|
40
49
|
"`add` and `edit`: repository-relative hints (directories or files) where the work happens. Not claims.",
|
|
41
50
|
}),
|
|
42
51
|
),
|
|
43
|
-
note: Type.Optional(
|
|
44
|
-
|
|
52
|
+
note: Type.Optional(
|
|
53
|
+
Type.String({
|
|
54
|
+
description: `\`add\`: a first note, e.g. why the card exists; one line, at most ${MAX_NOTE} characters.`,
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
text: Type.Optional(
|
|
58
|
+
Type.String({ description: `\`note\`: the note to append; one line, at most ${MAX_NOTE} characters.` }),
|
|
59
|
+
),
|
|
45
60
|
path: Type.Optional(Type.String({ description: "`plan`: the plan file this card is executing." })),
|
|
46
61
|
priority: Type.Optional(
|
|
47
62
|
Type.Union(
|
|
@@ -64,7 +79,7 @@ export const BoardParams = Type.Object({
|
|
|
64
79
|
query: Type.Optional(
|
|
65
80
|
Type.String({
|
|
66
81
|
description:
|
|
67
|
-
"`list`: show only the cards whose title, path hint, or
|
|
82
|
+
"`list`: show only the cards whose title, holder, path hint, plan, or a note's text matches this text, case-insensitively.",
|
|
68
83
|
}),
|
|
69
84
|
),
|
|
70
85
|
round: Type.Optional(
|
|
@@ -72,4 +87,10 @@ export const BoardParams = Type.Object({
|
|
|
72
87
|
description: "`list`: also return the lead's rollup — owners, unassigned, unplanned, blocked, idle.",
|
|
73
88
|
}),
|
|
74
89
|
),
|
|
90
|
+
archived: Type.Optional(
|
|
91
|
+
Type.Boolean({
|
|
92
|
+
description:
|
|
93
|
+
"`list`: read the archive instead of the board — the finished cards `archive` set aside, which no other read opens. `query` and `mine` narrow it; `status` and `round` do not apply to a pile that is out of the board.",
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
75
96
|
});
|