@gavin-hu/my-pi-agent 0.20.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 +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/extensions/README.md +1 -0
- package/docs/extensions/board.md +57 -42
- package/docs/extensions/bytefly.md +85 -89
- package/docs/extensions/file-browse.md +36 -41
- package/docs/extensions/plan.md +31 -14
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +1 -1
- package/docs/extensions/team.md +11 -11
- package/extensions/board/commands.ts +27 -14
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +53 -83
- package/extensions/board/result.ts +1 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +9 -3
- package/extensions/board/snapshot.ts +7 -6
- package/extensions/board/tools.ts +27 -15
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +0 -10
- package/extensions/board/view/client.ts +3 -2
- package/extensions/board/view/live.ts +1 -7
- package/extensions/board/view/page.ts +16 -5
- package/extensions/board/view/render.ts +22 -17
- package/extensions/board/view/styles.ts +7 -1
- package/extensions/bytefly/footer/config.ts +3 -6
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- 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 +4 -4
- package/extensions/bytefly/footer/render/segments.ts +10 -34
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- 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 -4
- 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/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- 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/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/index.ts +7 -1
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/runtime.ts +12 -2
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +99 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-settle.ts +14 -7
- 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/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/tools.ts +5 -4
- package/extensions/team/commands.ts +3 -1
- package/extensions/todo/tui.ts +21 -3
- package/lib/README.md +5 -4
- package/lib/board/page-pointer.ts +29 -4
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +34 -8
- package/lib/board/view.ts +20 -14
- package/lib/bus-request.ts +83 -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 -46
- package/lib/http/server.ts +3 -3
- package/lib/plan-entry.ts +9 -13
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-tool.ts +37 -21
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +2 -4
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/markup.ts +18 -1
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
package/extensions/board/join.ts
CHANGED
|
@@ -2,30 +2,51 @@
|
|
|
2
2
|
* Board's side of the team: the one call that puts this session on it.
|
|
3
3
|
*
|
|
4
4
|
* The user's surface is `/board` and `team`; the session bus is the transport under
|
|
5
|
-
* them. So the board joins on the user's behalf — when the page opens, and
|
|
6
|
-
*
|
|
5
|
+
* them. So the board joins on the user's behalf — when the page opens, and the first
|
|
6
|
+
* time this session claims a card — and never asks anybody to run a bus action.
|
|
7
|
+
*
|
|
8
|
+
* The label it asks for is the session's own team name (`BoardSession.who`), so the
|
|
9
|
+
* card, the bus record, and the roster say one word, and joining never renames the
|
|
10
|
+
* session: the footer keeps the name the user gave it.
|
|
7
11
|
*
|
|
8
12
|
* The seam exists for the same reason `team`'s dispatch does: a board suite drives a
|
|
9
13
|
* recording join instead of a bus, and the shipped implementation is one in-process
|
|
10
14
|
* request through `lib/session-tool.ts` (a command context cannot call a tool).
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
17
|
+
import { messageOf } from "../../lib/errors.ts";
|
|
18
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { JoinResult, TeamJoin } from "../../lib/session-tool.ts";
|
|
20
|
+
import type { TeamStanding } from "../../lib/board/session.ts";
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
23
|
* The join itself is `lib/session-tool.ts`'s, because `team` asks for it too; this
|
|
18
|
-
* module only holds what is the board's —
|
|
19
|
-
*
|
|
24
|
+
* module only holds what is the board's — when it asks, and how the outcome reads on a
|
|
25
|
+
* `/board` line.
|
|
20
26
|
*/
|
|
21
27
|
export type { TeamJoin } from "../../lib/session-tool.ts";
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
30
|
+
* Put this session on the team, once: the first card it claims.
|
|
31
|
+
*
|
|
32
|
+
* Asked only while the standing does not say this session is on the team, so a card
|
|
33
|
+
* write costs one request for the session rather than one per card. A failure is
|
|
34
|
+
* recorded and asked again by the next claim; a bus that is not loaded answers with a
|
|
35
|
+
* sentence, which is a result and not an exception — and a join that did throw still
|
|
36
|
+
* must not undo the card that was already written, so it is swallowed here.
|
|
26
37
|
*/
|
|
27
|
-
export function
|
|
28
|
-
|
|
38
|
+
export async function joinOnClaim(
|
|
39
|
+
standing: TeamStanding,
|
|
40
|
+
team: TeamJoin,
|
|
41
|
+
ctx: ExtensionContext,
|
|
42
|
+
label: string,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
if (standing.last()?.ok === true) return;
|
|
45
|
+
try {
|
|
46
|
+
standing.record(await team.join(ctx, { label }));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
standing.record({ ok: false, error: messageOf(error) });
|
|
49
|
+
}
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
/**
|
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
|
-
* 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,
|
|
@@ -29,25 +29,16 @@ import {
|
|
|
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";
|
|
33
34
|
import type { IdleReason } from "../../lib/http/sink.ts";
|
|
34
35
|
import { join } from "node:path";
|
|
35
|
-
import { type BoardConfig, loadBoardConfig } from "./config.ts";
|
|
36
36
|
import { pageActor } from "./seat.ts";
|
|
37
37
|
import { viewOf } from "./snapshot.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
|
*
|
|
@@ -81,6 +72,8 @@ export interface BoardPageStatus {
|
|
|
81
72
|
seat?: string;
|
|
82
73
|
port?: number;
|
|
83
74
|
url?: string;
|
|
75
|
+
/** The repository's name, so `status` says which board the page is. */
|
|
76
|
+
repo?: string;
|
|
84
77
|
where?: string;
|
|
85
78
|
/** How many tabs are attached, while this session serves the page. */
|
|
86
79
|
watchers?: number;
|
|
@@ -94,14 +87,17 @@ export interface BoardPageStatus {
|
|
|
94
87
|
* page* is a third answer a `running: false` could not express.
|
|
95
88
|
*/
|
|
96
89
|
export type PageStart =
|
|
97
|
-
| { kind: "served"; seat: string; port: number; url: string; where: string }
|
|
98
|
-
| { 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 }
|
|
99
92
|
| { kind: "failed"; reason: string };
|
|
100
93
|
|
|
101
94
|
/** Test seams and the one place a caller may redirect the browser. */
|
|
102
95
|
export interface BoardPageDeps {
|
|
103
96
|
open?: (url: string) => void;
|
|
104
|
-
|
|
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;
|
|
105
101
|
interval?: RepeatTimer;
|
|
106
102
|
/** Grace after the last tab leaves; defaults to {@link IDLE_STOP_MS}. */
|
|
107
103
|
idleMs?: number;
|
|
@@ -109,14 +105,13 @@ export interface BoardPageDeps {
|
|
|
109
105
|
firstIdleMs?: number;
|
|
110
106
|
/** Whether a page another session left a note about is answering; a test drives it. */
|
|
111
107
|
probe?: ProbeFetch;
|
|
112
|
-
/** How the `board` section answers; a test drives it without a config file. */
|
|
113
|
-
config?: (cwd: string) => BoardConfig;
|
|
114
108
|
}
|
|
115
109
|
|
|
116
110
|
/** The page as `/board` and `session_shutdown` drive it. */
|
|
117
111
|
export interface BoardPage {
|
|
118
112
|
start: (ctx: ExtensionContext, view?: PageView) => Promise<PageStart>;
|
|
119
|
-
|
|
113
|
+
/** Give the prefix back; the session's server closes with its last mount. */
|
|
114
|
+
stop: () => Promise<boolean>;
|
|
120
115
|
/** What `/board status` prints: served here, watched, watched and dead, or absent. */
|
|
121
116
|
status: () => Promise<BoardPageStatus>;
|
|
122
117
|
/** Drop the note of a page that has stopped answering. False when it still answers. */
|
|
@@ -124,11 +119,10 @@ export interface BoardPage {
|
|
|
124
119
|
closeAll: () => Promise<void>;
|
|
125
120
|
}
|
|
126
121
|
|
|
127
|
-
export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps
|
|
122
|
+
export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps): BoardPage {
|
|
128
123
|
let running: { server: BoardServer; store: BoardStore; token: string } | undefined;
|
|
129
124
|
/** The page another session serves, when this session opened that one instead. */
|
|
130
125
|
let attachedTo: PagePointer | undefined;
|
|
131
|
-
let sessionPort: number | undefined;
|
|
132
126
|
// The page is a seat, not a session: one name and one id for its whole life, so a card
|
|
133
127
|
// a browser claimed is still the seat's after the session that served it ends.
|
|
134
128
|
const seat = pageActor();
|
|
@@ -138,22 +132,25 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
138
132
|
/**
|
|
139
133
|
* Close the page this session serves, and take its note with it.
|
|
140
134
|
*
|
|
141
|
-
* One close for both callers: `/board stop` has a context and clears the
|
|
142
|
-
* clean `session_shutdown` has none. The note must leave either way — a note nobody
|
|
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
|
|
143
137
|
* answers makes every open tab reconnect forever and costs the next `/board` a probe
|
|
144
138
|
* and a takeover before it serves.
|
|
145
139
|
*/
|
|
146
|
-
const closeRunning = async (
|
|
140
|
+
const closeRunning = async (): Promise<boolean> => {
|
|
147
141
|
if (running === undefined) return false;
|
|
148
142
|
const { server, store, token } = running;
|
|
149
143
|
running = undefined;
|
|
150
144
|
await server.close();
|
|
151
145
|
await clearPointer(store.dir, token);
|
|
152
|
-
|
|
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);
|
|
153
150
|
return true;
|
|
154
151
|
};
|
|
155
152
|
|
|
156
|
-
const stop = (
|
|
153
|
+
const stop = (): Promise<boolean> => closeRunning();
|
|
157
154
|
|
|
158
155
|
const start = async (ctx: ExtensionContext, view: PageView = "board"): Promise<PageStart> => {
|
|
159
156
|
// `/board` is how the page is summoned, and a page whose tab was closed is one
|
|
@@ -162,11 +159,9 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
162
159
|
(deps.open ?? openInBrowser)(view === "board" ? running.server.url : `${running.server.url}&view=team`);
|
|
163
160
|
return describe(running);
|
|
164
161
|
}
|
|
165
|
-
const config = (deps.config ?? loadBoardConfig)(ctx.cwd);
|
|
166
|
-
const pinned = config.port > 0 ? config.port : undefined;
|
|
167
162
|
/** One refusal for every way a start can fail, named by what failed. */
|
|
168
163
|
const failed = (error: unknown): PageStart => {
|
|
169
|
-
const reason =
|
|
164
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
170
165
|
ctx.ui.notify(`board: the page did not start (${reason})`, "error");
|
|
171
166
|
return { kind: "failed", reason };
|
|
172
167
|
};
|
|
@@ -189,6 +184,7 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
189
184
|
seat: pointer.seat.label,
|
|
190
185
|
port: pointer.port,
|
|
191
186
|
url: urlOf(pointer),
|
|
187
|
+
repo: store.name(),
|
|
192
188
|
where: store.where(),
|
|
193
189
|
};
|
|
194
190
|
};
|
|
@@ -199,34 +195,44 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
199
195
|
attachedTo = undefined;
|
|
200
196
|
const actor = () => seat;
|
|
201
197
|
const now = () => runtime.now();
|
|
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.
|
|
204
|
-
const preferred = pinned ?? sessionPort ?? (deps.pickPort ?? randomPort)();
|
|
205
198
|
// Minted here rather than by the server: the note this session leaves has to carry it,
|
|
206
199
|
// and that is what a second session reads to attach instead of serving its own page.
|
|
207
200
|
const token = mintToken();
|
|
208
|
-
|
|
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
|
+
};
|
|
209
211
|
try {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
ports: pinned === undefined ? [preferred, 0] : [pinned],
|
|
212
|
+
const started = await createBoardServer({
|
|
213
|
+
mount: deps.mount,
|
|
214
|
+
session: deps.session(),
|
|
214
215
|
token,
|
|
215
216
|
...(deps.interval === undefined ? {} : { interval: deps.interval }),
|
|
217
|
+
onError: warnBlind,
|
|
216
218
|
idleMs: deps.idleMs ?? IDLE_STOP_MS,
|
|
217
219
|
firstIdleMs: deps.firstIdleMs ?? IDLE_FIRST_MS,
|
|
218
220
|
onIdle: (reason) => void stopIdle(ctx, reason),
|
|
219
|
-
page: async (view) => renderBoardPage({ view }),
|
|
221
|
+
page: async (view) => renderBoardPage({ view, base: BOARD_BASE }),
|
|
220
222
|
script: BOARD_CLIENT_JS,
|
|
221
223
|
view: () =>
|
|
222
224
|
viewOf({
|
|
223
225
|
store,
|
|
224
226
|
actor,
|
|
225
|
-
session: () => runtime.who(ctx, ctx.cwd),
|
|
226
227
|
now,
|
|
227
228
|
busDir: () => runtime.busDir(ctx.cwd),
|
|
228
229
|
}),
|
|
229
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;
|
|
230
236
|
// The note and the seat: written last, on the port the server actually bound, and only if no
|
|
231
237
|
// other session got there first: the loser of that race attaches to the page that
|
|
232
238
|
// won rather than serving a second page nobody can find. A note that answers
|
|
@@ -263,22 +269,17 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
263
269
|
ctx.ui.notify(`board: the page did not start (${reason})`, "error");
|
|
264
270
|
return { kind: "failed", reason };
|
|
265
271
|
}
|
|
266
|
-
sessionPort = server.port;
|
|
267
272
|
running = { server, store, token };
|
|
268
|
-
setChip(ctx, server.port);
|
|
269
273
|
open(server.url);
|
|
270
274
|
return describe(running);
|
|
271
275
|
} catch (error) {
|
|
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
276
|
return failed(error);
|
|
276
277
|
}
|
|
277
278
|
};
|
|
278
279
|
|
|
279
280
|
/** The idle grace closes the page and says which grace it was, once. */
|
|
280
281
|
const stopIdle = async (ctx: ExtensionContext, reason: IdleReason): Promise<void> => {
|
|
281
|
-
if (!(await stop(
|
|
282
|
+
if (!(await stop())) return;
|
|
282
283
|
ctx.ui.notify(
|
|
283
284
|
reason === "first"
|
|
284
285
|
? "board: stopped — no tab ever opened; /board starts it again"
|
|
@@ -292,6 +293,7 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
292
293
|
seat: seat.label,
|
|
293
294
|
port: state.server.port,
|
|
294
295
|
url: state.server.url,
|
|
296
|
+
repo: state.store.name(),
|
|
295
297
|
where: state.store.where(),
|
|
296
298
|
});
|
|
297
299
|
|
|
@@ -310,6 +312,7 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
310
312
|
seat: seat.label,
|
|
311
313
|
port: running.server.port,
|
|
312
314
|
url: running.server.url,
|
|
315
|
+
repo: running.store.name(),
|
|
313
316
|
where: running.store.where(),
|
|
314
317
|
watchers: running.server.watchers(),
|
|
315
318
|
};
|
|
@@ -353,36 +356,3 @@ export function createBoardPage(runtime: BoardSession, deps: BoardPageDeps = {})
|
|
|
353
356
|
},
|
|
354
357
|
};
|
|
355
358
|
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Why a start failed, naming the setting when the pinned port was the reason: a
|
|
359
|
-
* bind error's own text says the address, not the key that chose it.
|
|
360
|
-
*/
|
|
361
|
-
function failureReason(error: unknown, pinned: number | undefined): string {
|
|
362
|
-
// The bind path rejects with the `listen` error, so `code` is there to read.
|
|
363
|
-
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
364
|
-
if (pinned !== undefined && code === "EADDRINUSE") {
|
|
365
|
-
return `port ${pinned} is in use — change "port" in the bytefly.json board section, or stop what answers on it`;
|
|
366
|
-
}
|
|
367
|
-
return error instanceof Error ? error.message : String(error);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** The chip naming the port, cleared with an empty text. */
|
|
371
|
-
function setChip(ctx: ExtensionContext, port: number): void {
|
|
372
|
-
try {
|
|
373
|
-
ctx.ui.setStatus(
|
|
374
|
-
STATUS_KEYS.board,
|
|
375
|
-
`${paint(ctx.ui.theme, "ok", GLYPHS.board)} ${paint(ctx.ui.theme, "accent", String(port))}`,
|
|
376
|
-
);
|
|
377
|
-
} catch {
|
|
378
|
-
// A host without a status line is not a reason to keep the page from starting.
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function clearChip(ctx: ExtensionContext): void {
|
|
383
|
-
try {
|
|
384
|
-
ctx.ui.setStatus(STATUS_KEYS.board, undefined);
|
|
385
|
-
} catch {
|
|
386
|
-
// Same as above: nothing to clear.
|
|
387
|
-
}
|
|
388
|
-
}
|
|
@@ -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";
|
|
@@ -84,25 +83,3 @@ function cardLine(theme: Theme, card: Card): string {
|
|
|
84
83
|
const lane = paint(theme, "accent", card.status);
|
|
85
84
|
return `${id} ${lane} ${SEPARATORS.item} ${paint(theme, "label", sanitizeLine(card.title))}`;
|
|
86
85
|
}
|
|
87
|
-
|
|
88
|
-
/** The expanded block, as a component the transcript can reuse across renders. */
|
|
89
|
-
export class BoardResult implements Component {
|
|
90
|
-
private readonly view: TailResult<Card>;
|
|
91
|
-
|
|
92
|
-
constructor(input: TailResultInput<Card>, theme: Theme) {
|
|
93
|
-
this.view = new TailResult(input, theme);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Update in place so the transcript reuses this component. */
|
|
97
|
-
setInput(input: TailResultInput<Card>, theme: Theme): void {
|
|
98
|
-
this.view.setInput(input, theme);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
invalidate(): void {
|
|
102
|
-
this.view.invalidate();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
render(width: number): string[] {
|
|
106
|
-
return this.view.render(width);
|
|
107
|
-
}
|
|
108
|
-
}
|
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,7 +8,7 @@
|
|
|
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({
|
|
@@ -49,8 +49,14 @@ export const BoardParams = Type.Object({
|
|
|
49
49
|
"`add` and `edit`: repository-relative hints (directories or files) where the work happens. Not claims.",
|
|
50
50
|
}),
|
|
51
51
|
),
|
|
52
|
-
note: Type.Optional(
|
|
53
|
-
|
|
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
|
+
),
|
|
54
60
|
path: Type.Optional(Type.String({ description: "`plan`: the plan file this card is executing." })),
|
|
55
61
|
priority: Type.Optional(
|
|
56
62
|
Type.Union(
|
|
@@ -18,8 +18,6 @@ export interface SnapshotContext {
|
|
|
18
18
|
store: BoardStore;
|
|
19
19
|
/** The actor every request is made and reported as: the page's seat. */
|
|
20
20
|
actor: () => Actor;
|
|
21
|
-
/** The session serving the page, which is who is reading, not who it writes as. */
|
|
22
|
-
session?: () => Actor;
|
|
23
21
|
now: () => number;
|
|
24
22
|
/** Where this repository's bus files its records. */
|
|
25
23
|
busDir: () => Promise<string>;
|
|
@@ -28,14 +26,17 @@ export interface SnapshotContext {
|
|
|
28
26
|
/** The snapshot the page renders, and the body of `/api/board`. */
|
|
29
27
|
export async function viewOf(context: SnapshotContext): Promise<BoardView> {
|
|
30
28
|
const roster = await readRoster(await context.busDir());
|
|
31
|
-
|
|
29
|
+
// One read of the pile answers both: a frame that asked `list` and `skipped` separately
|
|
30
|
+
// would parse every card twice, once a second, for as long as a tab is open.
|
|
31
|
+
const { cards, skipped } = await context.store.scan();
|
|
32
32
|
return buildView({
|
|
33
33
|
me: context.actor(),
|
|
34
|
-
|
|
35
|
-
cards: await context.store.list(),
|
|
34
|
+
cards,
|
|
36
35
|
now: context.now(),
|
|
37
36
|
bytes: await context.store.bytes(),
|
|
38
|
-
skipped:
|
|
37
|
+
skipped: skipped.length,
|
|
38
|
+
repo: context.store.name(),
|
|
39
|
+
root: context.store.root(),
|
|
39
40
|
where: context.store.where(),
|
|
40
41
|
...(roster === undefined ? {} : { roster }),
|
|
41
42
|
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { Static } from "typebox";
|
|
17
|
-
import { cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
|
|
17
|
+
import { type Card, cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
|
|
18
18
|
import { indexOf } from "../../lib/board/links.ts";
|
|
19
19
|
import { type CardOp, holds } from "../../lib/board/rules.ts";
|
|
20
20
|
import { buildRound } from "../../lib/board/round.ts";
|
|
@@ -25,7 +25,7 @@ import { strayField } from "../../lib/stray-fields.ts";
|
|
|
25
25
|
import { createExecRunner } from "../../lib/git/runner.ts";
|
|
26
26
|
import { failedResult, textResult } from "../../lib/tool-result.ts";
|
|
27
27
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
28
|
-
import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
|
|
28
|
+
import { callLine, detailsOrLine, isTailResult, TailResult } from "../../lib/tui/tool.ts";
|
|
29
29
|
import { formatRound } from "../../lib/board/round-text.ts";
|
|
30
30
|
import {
|
|
31
31
|
archiveNote,
|
|
@@ -37,7 +37,8 @@ import {
|
|
|
37
37
|
formatSkipped,
|
|
38
38
|
} from "./format.ts";
|
|
39
39
|
import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
|
|
40
|
-
import {
|
|
40
|
+
import { joinOnClaim } from "./join.ts";
|
|
41
|
+
import { collapsedLine, resultInput } from "./result.ts";
|
|
41
42
|
import { createInput } from "./input.ts";
|
|
42
43
|
import type { BoardSession } from "../../lib/board/session.ts";
|
|
43
44
|
import { BoardParams } from "./schema.ts";
|
|
@@ -45,7 +46,6 @@ import {
|
|
|
45
46
|
ACTION_FIELDS,
|
|
46
47
|
BOARD_ACTION_DOCS,
|
|
47
48
|
BOARD_ACTIONS,
|
|
48
|
-
BOARD_MUTATING_ACTIONS,
|
|
49
49
|
BOARD_TOOL,
|
|
50
50
|
type BoardAction,
|
|
51
51
|
type BoardDetails,
|
|
@@ -96,6 +96,17 @@ function refusal(action: BoardAction, me: BoardDetails["me"], message: string, c
|
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Whether a result this tool built is a refusal.
|
|
101
|
+
*
|
|
102
|
+
* `refusal` marks one with `isError`, and the success shape (`ToolResult`) has no such
|
|
103
|
+
* field, so this is the one probe that tells the two apart — read here rather than
|
|
104
|
+
* widening the shared envelope with a flag that would then be optional everywhere.
|
|
105
|
+
*/
|
|
106
|
+
function refused(result: unknown): boolean {
|
|
107
|
+
return typeof result === "object" && result !== null && (result as { isError?: unknown }).isError === true;
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
/**
|
|
100
111
|
* The commit at `cwd`'s `HEAD`, shortened, or nothing when there is none.
|
|
101
112
|
*
|
|
@@ -149,13 +160,6 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
149
160
|
// have enlisted this session as a teammate on its way to the refusal.
|
|
150
161
|
const stray = strayField(action, args, ACTION_FIELDS);
|
|
151
162
|
if (stray !== undefined) return refusal(action, me, stray);
|
|
152
|
-
// Working a card makes this session a teammate, once: the board is the user's
|
|
153
|
-
// surface for the team, so nobody has to know the bus exists. A failed join is
|
|
154
|
-
// not a failed card write — the board works with no bus at all — and it is not
|
|
155
|
-
// marked, so the next write asks again.
|
|
156
|
-
if (BOARD_MUTATING_ACTIONS.has(action) && !runtime.joined()) {
|
|
157
|
-
if ((await team.join(ctx)).ok) runtime.markJoined();
|
|
158
|
-
}
|
|
159
163
|
const store = await runtime.store(ctx.cwd);
|
|
160
164
|
const now = runtime.now();
|
|
161
165
|
switch (action) {
|
|
@@ -163,8 +167,16 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
163
167
|
return await read(store, action, me, args, now);
|
|
164
168
|
case "add":
|
|
165
169
|
return await add(store, me, args, now);
|
|
166
|
-
case "claim":
|
|
167
|
-
|
|
170
|
+
case "claim": {
|
|
171
|
+
const claimed = await mutate(store, action, me, now, args, { kind: "claim" });
|
|
172
|
+
// Taking a card is what makes this session a teammate. It is the one action here
|
|
173
|
+
// worth being addressed about, and the board is the user's surface for the team, so
|
|
174
|
+
// nobody has to know the bus exists. A refused claim wrote nothing and enlists
|
|
175
|
+
// nobody; a refused join is not a failed claim, so the card stands and the next
|
|
176
|
+
// claim asks again.
|
|
177
|
+
if (!refused(claimed)) await joinOnClaim(runtime.team, team, ctx, me.label);
|
|
178
|
+
return claimed;
|
|
179
|
+
}
|
|
168
180
|
case "release":
|
|
169
181
|
return await mutate(store, action, me, now, args, { kind: "release" });
|
|
170
182
|
case "reclaim":
|
|
@@ -239,8 +251,8 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
239
251
|
text.setText(collapsedLine(theme, outcome.details));
|
|
240
252
|
return text;
|
|
241
253
|
}
|
|
242
|
-
const
|
|
243
|
-
|
|
254
|
+
const previous = context?.lastComponent;
|
|
255
|
+
const view = isTailResult<Card>(previous) ? previous : new TailResult<Card>(input, theme);
|
|
244
256
|
view.setInput(input, theme);
|
|
245
257
|
return view;
|
|
246
258
|
},
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type ServeResponse,
|
|
24
24
|
} from "../../../lib/http/index.ts";
|
|
25
25
|
import type { BoardView } from "../../../lib/board/view.ts";
|
|
26
|
+
import { PING_PATH, SNAPSHOT_PATH } from "../../../lib/board/page-pointer.ts";
|
|
26
27
|
import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
|
|
27
28
|
import type { PageView } from "../view/page.ts";
|
|
28
29
|
|
|
@@ -45,13 +46,10 @@ export interface BoardRouterContext {
|
|
|
45
46
|
page: (view: PageView) => Promise<string>;
|
|
46
47
|
/** The client script. */
|
|
47
48
|
script: string;
|
|
48
|
-
/** The snapshot the page renders, and the body of
|
|
49
|
+
/** The snapshot the page renders, and the body of the snapshot route. */
|
|
49
50
|
view: () => Promise<BoardView>;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/** The cheap liveness route: a probe reads this, and never the snapshot. */
|
|
53
|
-
export const PING_PATH = "/api/ping";
|
|
54
|
-
|
|
55
53
|
/**
|
|
56
54
|
* Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values.
|
|
57
55
|
*/
|
|
@@ -77,7 +75,7 @@ export async function handleBoardRequest(
|
|
|
77
75
|
// must not pay for the snapshot, which is a store read and a roster read on the same
|
|
78
76
|
// event loop the probe is racing.
|
|
79
77
|
if (pathname === PING_PATH) return jsonResponse(200, { ok: true });
|
|
80
|
-
if (pathname ===
|
|
78
|
+
if (pathname === SNAPSHOT_PATH) return jsonResponse(200, await ctx.view());
|
|
81
79
|
if (pathname === "/" || pathname === "/index.html") {
|
|
82
80
|
return { status: 200, headers: pageHeaders(), body: await ctx.page(viewOf(search.get("view"))) };
|
|
83
81
|
}
|