@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 +46 -30
- package/README.zh-CN.md +40 -25
- package/assets/screens/board.png +0 -0
- package/assets/screens/chrome.png +0 -0
- package/assets/screens/file-browse.png +0 -0
- package/assets/screens/plan-review.png +0 -0
- package/assets/screens/plan.png +0 -0
- package/docs/extensions/README.md +22 -23
- package/docs/extensions/board.md +397 -168
- package/docs/extensions/bytefly.md +234 -197
- package/docs/extensions/check.md +15 -17
- package/docs/extensions/context.md +35 -15
- package/docs/extensions/file-browse.md +57 -63
- package/docs/extensions/file-open.md +9 -11
- package/docs/extensions/file-read.md +12 -14
- package/docs/extensions/goal.md +15 -17
- package/docs/extensions/job.md +54 -42
- package/docs/extensions/memory.md +17 -19
- package/docs/extensions/plan.md +197 -140
- package/docs/extensions/question.md +16 -18
- package/docs/extensions/rewind.md +17 -19
- package/docs/extensions/serve.md +145 -0
- package/docs/extensions/session-bus.md +38 -16
- package/docs/extensions/subagent.md +23 -25
- package/docs/extensions/team.md +83 -44
- package/docs/extensions/todo.md +18 -18
- package/docs/extensions/watch.md +31 -31
- package/docs/extensions/web-access.md +2 -4
- package/docs/extensions/wechat.md +34 -35
- package/docs/extensions/worktree.md +56 -50
- package/extensions/board/commands.ts +79 -25
- package/extensions/board/format.ts +43 -24
- package/extensions/board/index.ts +17 -4
- package/extensions/board/input.ts +4 -9
- package/extensions/board/join.ts +5 -20
- package/extensions/board/lazy-page.ts +40 -0
- package/extensions/board/page.ts +65 -93
- package/extensions/board/result.ts +30 -39
- package/extensions/board/rows.ts +62 -25
- package/extensions/board/schema.ts +49 -11
- package/extensions/board/snapshot.ts +8 -6
- package/extensions/board/tone.ts +22 -0
- package/extensions/board/tools.ts +154 -55
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +36 -42
- package/extensions/board/view/card.ts +229 -0
- package/extensions/board/view/client.ts +35 -16
- package/extensions/board/view/dialog.ts +169 -6
- package/extensions/board/view/empty.ts +47 -0
- package/extensions/board/view/keys.ts +13 -2
- package/extensions/board/view/live.ts +90 -28
- package/extensions/board/view/names.ts +13 -4
- package/extensions/board/view/page.ts +38 -13
- package/extensions/board/view/render.ts +219 -277
- package/extensions/board/view/styles.ts +130 -32
- package/extensions/board/view/team.ts +297 -76
- package/extensions/bytefly/block/fallback.ts +1 -1
- package/extensions/bytefly/doctor/runtime.ts +5 -2
- package/extensions/bytefly/footer/collect/label.ts +60 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
- package/extensions/bytefly/footer/config.ts +7 -8
- package/extensions/bytefly/footer/register.ts +33 -4
- 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 +10 -7
- package/extensions/bytefly/footer/render/segments.ts +41 -38
- package/extensions/bytefly/footer/render/status.ts +0 -11
- package/extensions/bytefly/footer/types.ts +7 -0
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/header/config.ts +1 -1
- package/extensions/bytefly/index.ts +32 -6
- package/extensions/bytefly/mode/editor.ts +115 -0
- package/extensions/bytefly/panel/commands.ts +210 -120
- package/extensions/bytefly/panel/format.ts +4 -4
- package/extensions/bytefly/panel/settings.ts +73 -6
- package/extensions/bytefly/panel/tui/keys.ts +2 -6
- package/extensions/bytefly/update/cache.ts +1 -1
- package/extensions/bytefly/update/register.ts +4 -4
- package/extensions/bytefly/working/config.ts +5 -7
- package/extensions/bytefly/working/format.ts +10 -13
- package/extensions/bytefly/working/register.ts +22 -27
- package/extensions/check/commands.ts +3 -2
- package/extensions/check/render.ts +10 -1
- package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
- package/extensions/context/commands.ts +2 -2
- package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +89 -79
- 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/file-read/render.ts +4 -4
- package/extensions/goal/plan-route.ts +2 -2
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/child.ts +527 -0
- package/extensions/job/clock.ts +142 -0
- package/extensions/job/commands.ts +4 -2
- package/extensions/job/config.ts +8 -1
- package/extensions/job/format.ts +22 -1
- package/extensions/job/log-writer.ts +13 -2
- package/extensions/job/registry.ts +63 -7
- package/extensions/job/render.ts +34 -0
- package/extensions/job/runtime.ts +296 -655
- package/extensions/job/session.ts +132 -19
- package/extensions/job/store.ts +7 -7
- package/extensions/job/tools.ts +21 -9
- package/extensions/job/types.ts +47 -0
- package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
- package/extensions/job/waiters.ts +44 -1
- package/extensions/memory/commands.ts +4 -2
- package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
- package/extensions/plan/commands.ts +12 -9
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/format.ts +8 -2
- package/extensions/plan/git-bash.ts +115 -16
- package/extensions/plan/index.ts +58 -51
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +5 -54
- package/extensions/plan/policy.ts +8 -10
- package/extensions/plan/review-lazy.ts +85 -0
- package/extensions/plan/review.ts +2 -2
- package/extensions/plan/runtime.ts +61 -41
- package/extensions/plan/sections.ts +1 -1
- package/extensions/plan/steps.ts +3 -3
- package/extensions/plan/todos.ts +1 -1
- package/extensions/plan/tools/cancel.ts +27 -62
- package/extensions/plan/tools/enter.ts +55 -81
- package/extensions/plan/tools/exit.ts +221 -238
- package/extensions/plan/tools/refusal.ts +18 -0
- package/extensions/plan/tools/steps.ts +75 -92
- package/extensions/plan/tools/write.ts +77 -120
- package/extensions/plan/tools.ts +188 -30
- package/extensions/plan/tui.ts +5 -5
- package/extensions/plan/types.ts +48 -5
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +102 -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-lazy.ts +53 -0
- package/extensions/plan/web-markdown.ts +10 -68
- 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-router.ts +18 -9
- 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 +67 -131
- package/extensions/question/tools.ts +6 -2
- package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
- package/extensions/rewind/commands/commands.ts +6 -3
- package/extensions/rewind/state/runtime.ts +1 -1
- package/extensions/rewind/{view/status.ts → status.ts} +4 -4
- package/extensions/rewind/view/review-tui.ts +4 -4
- package/extensions/serve/config.ts +39 -0
- package/extensions/serve/index.ts +85 -0
- package/extensions/serve/registry.ts +260 -0
- package/extensions/session-bus/commands.ts +11 -6
- package/extensions/session-bus/join-request.ts +17 -9
- package/extensions/session-bus/mail-watch.ts +26 -17
- package/extensions/session-bus/protocol.ts +15 -3
- package/extensions/session-bus/resolve.ts +5 -7
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/runtime.ts +40 -12
- package/extensions/session-bus/store.ts +101 -19
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/types.ts +19 -0
- package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/commands.ts +4 -2
- package/extensions/subagent/format.ts +15 -15
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +3 -3
- package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
- package/extensions/team/commands.ts +24 -5
- package/extensions/team/dispatch.ts +18 -4
- package/extensions/team/format.ts +17 -2
- package/extensions/team/index.ts +9 -8
- package/extensions/team/schema.ts +6 -5
- package/extensions/team/tools.ts +107 -45
- package/extensions/team/types.ts +3 -2
- package/extensions/todo/commands.ts +4 -2
- package/extensions/todo/format.ts +11 -0
- package/extensions/todo/render.ts +57 -0
- package/extensions/todo/runtime.ts +1 -1
- package/extensions/todo/tools.ts +1 -1
- package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
- package/extensions/todo/widget.ts +47 -0
- package/extensions/watch/commands.ts +4 -2
- package/extensions/watch/format.ts +36 -7
- package/extensions/watch/render.ts +57 -0
- package/extensions/watch/runtime.ts +27 -22
- package/extensions/watch/tools.ts +4 -4
- package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
- package/extensions/web-access/fetch/tool.ts +12 -4
- package/extensions/wechat/commands/commands.ts +18 -14
- package/extensions/wechat/index.ts +72 -37
- package/extensions/wechat/state/credentials.ts +1 -1
- package/extensions/wechat/state/state.ts +1 -1
- package/extensions/wechat/text/status.ts +112 -0
- package/extensions/wechat/types.ts +2 -2
- package/extensions/wechat/view/status-screen.ts +4 -103
- package/extensions/worktree/commands.ts +11 -9
- package/extensions/worktree/enter.ts +2 -2
- package/extensions/worktree/exit.ts +2 -2
- package/extensions/worktree/index.ts +5 -5
- package/extensions/worktree/screen.ts +3 -3
- package/extensions/worktree/status.ts +2 -2
- package/extensions/worktree/tools.ts +113 -116
- package/lib/README.md +28 -13
- package/lib/board/actions.ts +111 -0
- package/lib/board/artifact.ts +149 -0
- package/lib/board/card.ts +163 -52
- package/lib/board/config.ts +58 -0
- package/lib/board/contract.ts +113 -0
- package/lib/board/join.ts +38 -0
- package/lib/board/lanes.ts +182 -0
- package/lib/board/lead.ts +130 -0
- package/lib/board/limits.ts +78 -0
- package/lib/board/links.ts +31 -10
- package/lib/board/page-pointer.ts +30 -5
- package/lib/board/read.ts +83 -0
- package/lib/board/reason.ts +102 -0
- package/lib/board/round-text.ts +68 -6
- package/lib/board/round.ts +31 -15
- package/lib/board/rules.ts +151 -307
- package/lib/board/seat.ts +35 -0
- package/lib/board/session.ts +199 -51
- package/lib/board/skipped.ts +22 -0
- package/lib/board/steps.ts +487 -0
- package/lib/board/store.ts +119 -36
- package/lib/board/tone.ts +92 -0
- package/lib/board/view.ts +81 -63
- package/lib/bus-request.ts +83 -0
- package/{extensions/watch → lib}/child.ts +40 -21
- package/lib/env.ts +65 -7
- package/lib/format.ts +13 -0
- package/lib/http/index.ts +17 -11
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +30 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/lazy.ts +45 -0
- package/lib/plan-entry.ts +46 -21
- package/lib/plans/paths.ts +82 -0
- package/lib/policy.ts +9 -6
- package/lib/report.ts +3 -2
- package/lib/requirements.ts +10 -10
- package/lib/session-label.ts +129 -7
- package/lib/session-roster.ts +43 -1
- package/lib/session-tool.ts +39 -22
- package/lib/stray-fields.ts +7 -2
- package/lib/tool-names.ts +5 -10
- package/lib/tui/README.md +29 -11
- package/lib/tui/chrome.ts +16 -15
- package/lib/tui/cursor.ts +18 -3
- package/lib/tui/glyphs.ts +11 -11
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tokens.ts +33 -2
- package/lib/tui/tool.ts +33 -4
- package/lib/web/README.md +21 -0
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/components.ts +5 -1
- package/lib/web/index.ts +1 -0
- package/lib/web/markdown.ts +83 -0
- package/lib/web/markup.ts +20 -2
- package/lib/web/runtime.ts +9 -10
- package/package.json +8 -2
- package/skills/init/SKILL.md +5 -5
- package/skills/next/SKILL.md +3 -5
- package/extensions/board/config.ts +0 -36
- package/extensions/board/seat.ts +0 -28
- package/lib/tui/index.ts +0 -34
- package/skills/project-docs/SKILL.md +0 -133
- package/skills/project-docs/references/audit.md +0 -131
- package/skills/project-docs/references/changelog.md +0 -105
- package/skills/project-docs/references/discovery.md +0 -127
- package/skills/project-docs/references/readme.md +0 -80
- package/skills/project-docs/references/release.md +0 -84
- package/skills/project-docs/references/roadmap.md +0 -93
- package/skills/project-docs/scripts/state.ts +0 -316
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's one server, the prefixes mounted on it, and the LAN listener a
|
|
3
|
+
* shared page borrows.
|
|
4
|
+
*
|
|
5
|
+
* The registry is the only place that knows what a session is serving: it binds on
|
|
6
|
+
* the first mount, remembers the port so a closed page reopens where it was, keys
|
|
7
|
+
* every server by Pi session id so a peer session never lands on the host's socket,
|
|
8
|
+
* and closes a session's socket with its last mount. A page that goes away takes
|
|
9
|
+
* only its own prefix with it.
|
|
10
|
+
*
|
|
11
|
+
* The port is a preference, not a reservation: a session's first bind tries the port
|
|
12
|
+
* it is pinned to, or the project's own number, and falls back to an OS-chosen one when
|
|
13
|
+
* that port is taken. Pinning is the session's, not a page's: `serve.port` belongs to the
|
|
14
|
+
* first session that binds, so a second session in this process (a WeChat peer loads every
|
|
15
|
+
* other extension, `extensions/wechat/peer/peer-session.ts`) falls back to an OS-chosen
|
|
16
|
+
* port instead of failing on the host's pin, and a second *process* is told the pin
|
|
17
|
+
* is in use rather than moved off the address a bookmark names.
|
|
18
|
+
*
|
|
19
|
+
* Value-only: every byte of state is in the returned closure.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { lazy } from "../../lib/lazy.ts";
|
|
23
|
+
import type {
|
|
24
|
+
HttpMount,
|
|
25
|
+
MountOutcome,
|
|
26
|
+
MountRequest,
|
|
27
|
+
NetworkInterfaces,
|
|
28
|
+
ServedMount,
|
|
29
|
+
ServedServer,
|
|
30
|
+
SharedListener,
|
|
31
|
+
} from "../../lib/http/index.ts";
|
|
32
|
+
|
|
33
|
+
/** One prefix as this session's server holds it: the mount, and the spec that remounts it. */
|
|
34
|
+
interface Held {
|
|
35
|
+
mount: { base: string; release(): Promise<void> };
|
|
36
|
+
spec: ServedMount;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One session's served state. */
|
|
40
|
+
interface Served {
|
|
41
|
+
server: ServedServer;
|
|
42
|
+
mounts: Map<string, Held>;
|
|
43
|
+
/** The LAN listener every shared page of this session answers on. */
|
|
44
|
+
shared?: ServedServer;
|
|
45
|
+
/** The address `shared` was bound to, which a link prints. */
|
|
46
|
+
address?: string;
|
|
47
|
+
/** The prefixes answered on `shared`, each with the mount that frees it again. */
|
|
48
|
+
sharedMounts: Map<string, { release(): Promise<void> }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the registry needs besides the request it is answering. */
|
|
52
|
+
export interface ServeRegistryDeps {
|
|
53
|
+
/** The port a session is pinned to, or `0` to pick one. */
|
|
54
|
+
portFor(session: string): number;
|
|
55
|
+
/**
|
|
56
|
+
* The port a session's first bind tries, before the OS-chosen fallback: the project's
|
|
57
|
+
* own number, so a session that starts again lands on the one a bookmark names.
|
|
58
|
+
*/
|
|
59
|
+
pickPort(session: string): number | Promise<number>;
|
|
60
|
+
/** The machine's interfaces; a test injects a snapshot instead. */
|
|
61
|
+
interfaces: () => NetworkInterfaces;
|
|
62
|
+
/** Told when a session's port comes up and when it goes away, so a chip can say so. */
|
|
63
|
+
onPort?: (session: string, port: number | undefined) => void;
|
|
64
|
+
/** The served-page kit; loaded on the first bind, so registration never pays for it. */
|
|
65
|
+
loadServe?: () => Promise<typeof import("../../lib/http/index.ts")>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The session's servers, as the extension's bus listener drives them. */
|
|
69
|
+
export interface ServeRegistry {
|
|
70
|
+
/** Answer one mount request: claims it synchronously, then binds. */
|
|
71
|
+
handle(request: MountRequest): void;
|
|
72
|
+
/** Close one session's socket, LAN listener included. */
|
|
73
|
+
closeSession(session: string): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A bind that failed, or the server it produced. */
|
|
77
|
+
type Opened = { kind: "open"; served: Served } | { kind: "failed"; reason: string };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Why a bind failed, naming the setting when the pinned port was the reason: a bind
|
|
81
|
+
* error's own text says the address, not the key that chose it.
|
|
82
|
+
*/
|
|
83
|
+
function refusalReason(error: unknown, pinned: number | undefined): string {
|
|
84
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
85
|
+
if (pinned !== undefined && code === "EADDRINUSE") {
|
|
86
|
+
return `port ${pinned} is in use — change "port" in the bytefly.json serve section, or stop what answers on it`;
|
|
87
|
+
}
|
|
88
|
+
return error instanceof Error ? error.message : String(error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createServeRegistry(deps: ServeRegistryDeps): ServeRegistry {
|
|
92
|
+
const sessions = new Map<string, Served>();
|
|
93
|
+
/** Binds in flight, so two pages opening together share one socket. */
|
|
94
|
+
const binding = new Map<string, Promise<Opened>>();
|
|
95
|
+
/** The port each session last bound, so a page that closed reopens where it was. */
|
|
96
|
+
const remembered = new Map<string, number>();
|
|
97
|
+
/** The session the pinned port belongs to; every later session picks its own. */
|
|
98
|
+
let pinnedBy: string | undefined;
|
|
99
|
+
/** The served-page kit: one load, on the first bind, shared by every later one. */
|
|
100
|
+
const http = lazy(deps.loadServe ?? (() => import("../../lib/http/index.ts")));
|
|
101
|
+
|
|
102
|
+
/** Bind `session`'s server on the pin when it is this session's to take. */
|
|
103
|
+
const bind = async (session: string): Promise<Opened> => {
|
|
104
|
+
const { serveHttp } = await http.get();
|
|
105
|
+
const pinned = deps.portFor(session);
|
|
106
|
+
const usePin = pinned > 0 && (pinnedBy === undefined || pinnedBy === session);
|
|
107
|
+
const preferred = remembered.get(session) ?? (await deps.pickPort(session));
|
|
108
|
+
try {
|
|
109
|
+
const server = await serveHttp({ ports: usePin ? [pinned] : [preferred, 0] });
|
|
110
|
+
if (usePin) pinnedBy = session;
|
|
111
|
+
remembered.set(session, server.port);
|
|
112
|
+
const served: Served = { server, mounts: new Map(), sharedMounts: new Map() };
|
|
113
|
+
sessions.set(session, served);
|
|
114
|
+
deps.onPort?.(session, server.port);
|
|
115
|
+
return { kind: "open", served };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return { kind: "failed", reason: refusalReason(error, usePin ? pinned : undefined) };
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** This session's server, binding it if no page has yet. */
|
|
122
|
+
const open = async (session: string): Promise<Opened> => {
|
|
123
|
+
const existing = sessions.get(session);
|
|
124
|
+
if (existing !== undefined) return { kind: "open", served: existing };
|
|
125
|
+
const inflight = binding.get(session);
|
|
126
|
+
if (inflight !== undefined) return inflight;
|
|
127
|
+
const started = bind(session).finally(() => binding.delete(session));
|
|
128
|
+
binding.set(session, started);
|
|
129
|
+
return started;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Withdraw one page from the LAN listener, whether it stopped sharing or closed. */
|
|
133
|
+
const withdraw = async (served: Served, base: string): Promise<void> => {
|
|
134
|
+
const mounted = served.sharedMounts.get(base);
|
|
135
|
+
if (mounted === undefined) return;
|
|
136
|
+
served.sharedMounts.delete(base);
|
|
137
|
+
await mounted.release();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Close the LAN listener once no page on this session is sharing itself. */
|
|
141
|
+
const closeLanIfEmpty = async (served: Served): Promise<void> => {
|
|
142
|
+
if (served.sharedMounts.size > 0) return;
|
|
143
|
+
const listener = served.shared;
|
|
144
|
+
served.shared = undefined;
|
|
145
|
+
served.address = undefined;
|
|
146
|
+
if (listener !== undefined) await listener.close();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Forget a session's server, which the socket has already closed. */
|
|
150
|
+
const closed = (session: string, served: Served): void => {
|
|
151
|
+
if (sessions.get(session) !== served) return;
|
|
152
|
+
sessions.delete(session);
|
|
153
|
+
deps.onPort?.(session, undefined);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const release = async (session: string, base: string): Promise<void> => {
|
|
157
|
+
const served = sessions.get(session);
|
|
158
|
+
const held = served?.mounts.get(base);
|
|
159
|
+
if (served === undefined || held === undefined) return;
|
|
160
|
+
served.mounts.delete(base);
|
|
161
|
+
// A page that closes stops being reachable from the network as well: its prefix comes
|
|
162
|
+
// off the LAN listener before the loopback one, so no link outlives the page.
|
|
163
|
+
await withdraw(served, base);
|
|
164
|
+
await held.mount.release();
|
|
165
|
+
// The last prefix takes the LAN listener with it, then the socket: a session with
|
|
166
|
+
// no pages has no port to answer on.
|
|
167
|
+
await closeLanIfEmpty(served);
|
|
168
|
+
if (served.mounts.size > 0) return;
|
|
169
|
+
closed(session, served);
|
|
170
|
+
await served.server.close();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const share = async (session: string, base: string): Promise<SharedListener | undefined> => {
|
|
174
|
+
const { serveHttp, lanAddresses } = await http.get();
|
|
175
|
+
const served = sessions.get(session);
|
|
176
|
+
const held = served?.mounts.get(base);
|
|
177
|
+
if (served === undefined || held === undefined) return undefined;
|
|
178
|
+
const address = served.address ?? lanAddresses(deps.interfaces())[0];
|
|
179
|
+
if (address === undefined) return undefined;
|
|
180
|
+
if (served.shared === undefined) {
|
|
181
|
+
try {
|
|
182
|
+
// The same port number on the LAN address, which keeps one link shape; an
|
|
183
|
+
// OS-chosen one when that address cannot take it.
|
|
184
|
+
served.shared = await serveHttp({ host: address, ports: [served.server.port, 0] });
|
|
185
|
+
served.address = address;
|
|
186
|
+
} catch {
|
|
187
|
+
served.shared = undefined;
|
|
188
|
+
served.address = undefined;
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!served.sharedMounts.has(base)) {
|
|
193
|
+
const lan = served.shared.mount({ ...held.spec, prefix: base });
|
|
194
|
+
if (lan === undefined) return undefined;
|
|
195
|
+
served.sharedMounts.set(base, lan);
|
|
196
|
+
}
|
|
197
|
+
return { address, origin: served.shared.origin };
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const unshare = async (session: string, base: string): Promise<void> => {
|
|
201
|
+
const served = sessions.get(session);
|
|
202
|
+
if (served === undefined) return;
|
|
203
|
+
// Stop sharing is a page leaving the network, not a counter going down: the prefix
|
|
204
|
+
// comes off the listener, and the socket closes only with the last page that shared.
|
|
205
|
+
await withdraw(served, base);
|
|
206
|
+
await closeLanIfEmpty(served);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Mount `request`'s prefix, or say why this session has none. */
|
|
210
|
+
const mount = async (request: MountRequest): Promise<MountOutcome> => {
|
|
211
|
+
const { spec } = request;
|
|
212
|
+
const opened = await open(spec.session);
|
|
213
|
+
if (opened.kind === "failed") return { kind: "refused", reason: opened.reason };
|
|
214
|
+
const { served } = opened;
|
|
215
|
+
if (served.mounts.has(spec.prefix)) {
|
|
216
|
+
return { kind: "refused", reason: `${spec.prefix} is already served on :${served.server.port}` };
|
|
217
|
+
}
|
|
218
|
+
const mounted = served.server.mount({
|
|
219
|
+
prefix: spec.prefix,
|
|
220
|
+
handler: spec.handler,
|
|
221
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
222
|
+
idle: spec.idle,
|
|
223
|
+
});
|
|
224
|
+
if (mounted === undefined) {
|
|
225
|
+
return { kind: "refused", reason: `${spec.prefix} is already served on :${served.server.port}` };
|
|
226
|
+
}
|
|
227
|
+
served.mounts.set(mounted.base, { mount: mounted, spec });
|
|
228
|
+
const handed: HttpMount = {
|
|
229
|
+
base: mounted.base,
|
|
230
|
+
origin: served.server.origin,
|
|
231
|
+
port: served.server.port,
|
|
232
|
+
release: () => release(spec.session, mounted.base),
|
|
233
|
+
share: () => share(spec.session, mounted.base),
|
|
234
|
+
unshare: () => unshare(spec.session, mounted.base),
|
|
235
|
+
};
|
|
236
|
+
return { kind: "mounted", mount: handed };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const closeSession = async (session: string): Promise<void> => {
|
|
240
|
+
const served = sessions.get(session);
|
|
241
|
+
remembered.delete(session);
|
|
242
|
+
if (served === undefined) return;
|
|
243
|
+
closed(session, served);
|
|
244
|
+
served.mounts.clear();
|
|
245
|
+
served.sharedMounts.clear();
|
|
246
|
+
await served.shared?.close();
|
|
247
|
+
served.shared = undefined;
|
|
248
|
+
await served.server.close();
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
handle(request) {
|
|
253
|
+
// A payload this extension does not recognise is left unclaimed, so the caller
|
|
254
|
+
// is refused rather than answered with a page nobody serves.
|
|
255
|
+
if (typeof request?.spec?.handler !== "function" || typeof request.respond !== "function") return;
|
|
256
|
+
request.respond(mount(request));
|
|
257
|
+
},
|
|
258
|
+
closeSession,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -12,11 +12,9 @@
|
|
|
12
12
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { messageOf } from "../../lib/errors.ts";
|
|
14
14
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
15
|
-
import { withPolledScreen } from "../../lib/screen-session.ts";
|
|
16
15
|
import { formatJoin, formatStatus } from "./format.ts";
|
|
17
16
|
import { COMMAND_NAME } from "./protocol.ts";
|
|
18
17
|
import type { SessionBus } from "./runtime.ts";
|
|
19
|
-
import { SessionListComponent } from "./tui.ts";
|
|
20
18
|
|
|
21
19
|
/** How often the open board re-reads the bus. */
|
|
22
20
|
const BOARD_POLL_MS = 1_000;
|
|
@@ -29,7 +27,7 @@ const BOARD_POLL_MS = 1_000;
|
|
|
29
27
|
* record fresh, so a poll never has to write. Both reads are read-only: the
|
|
30
28
|
* snapshot neither prunes nor refreshes, and the peek neither consumes nor marks.
|
|
31
29
|
*/
|
|
32
|
-
function createBoardReader(bus: SessionBus): (screen:
|
|
30
|
+
function createBoardReader(bus: SessionBus): (screen: SessionsScreen) => Promise<void> {
|
|
33
31
|
let busy = false;
|
|
34
32
|
return async (screen) => {
|
|
35
33
|
if (busy) return;
|
|
@@ -45,12 +43,19 @@ function createBoardReader(bus: SessionBus): (screen: SessionListComponent) => P
|
|
|
45
43
|
};
|
|
46
44
|
}
|
|
47
45
|
|
|
46
|
+
/** The mounted board's type, without loading the screen at registration. */
|
|
47
|
+
type SessionsScreen = InstanceType<typeof import("./view/session-list.ts")["SessionListComponent"]>;
|
|
48
|
+
|
|
48
49
|
/** Open the TUI board, and resolve when it closes. */
|
|
49
50
|
async function openSessionsScreen(pi: ExtensionAPI, ctx: ExtensionCommandContext, bus: SessionBus): Promise<void> {
|
|
51
|
+
// The board is the TUI's: the text paths below and registration load neither it
|
|
52
|
+
// nor the dock screen's poll loop.
|
|
53
|
+
const { withPolledScreen } = await import("../../lib/screen-session.ts");
|
|
54
|
+
const { SessionListComponent } = await import("./view/session-list.ts");
|
|
50
55
|
await bus.start(ctx);
|
|
51
56
|
const [status, messages] = await Promise.all([bus.snapshot(), bus.peek()]);
|
|
52
57
|
const first = { status, messages };
|
|
53
|
-
let read: ((screen:
|
|
58
|
+
let read: ((screen: SessionsScreen) => Promise<void>) | undefined;
|
|
54
59
|
await withPolledScreen(
|
|
55
60
|
pi,
|
|
56
61
|
ctx,
|
|
@@ -59,9 +64,9 @@ async function openSessionsScreen(pi: ExtensionAPI, ctx: ExtensionCommandContext
|
|
|
59
64
|
read = reader;
|
|
60
65
|
// The verbs repaint the board they belong to; their callbacks only run after
|
|
61
66
|
// construction, so a holder keeps the wiring one-directional.
|
|
62
|
-
const holder: { screen?:
|
|
67
|
+
const holder: { screen?: SessionsScreen } = {};
|
|
63
68
|
const component = new SessionListComponent({
|
|
64
|
-
|
|
69
|
+
board: first,
|
|
65
70
|
theme: screen.theme,
|
|
66
71
|
callbacks: {
|
|
67
72
|
toggleWatch: async (armed) => {
|
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
* the same limit `lib/plan-entry.ts` works around for plan mode — so the request
|
|
8
8
|
* travels on `pi.events` and this module answers it with the runtime's own `join`.
|
|
9
9
|
*
|
|
10
|
-
* The answer always comes: every path here
|
|
11
|
-
* a caller never waits on a listener that threw.
|
|
12
|
-
* name, because
|
|
10
|
+
* The answer always comes: every path here returns a result, including a failure, so
|
|
11
|
+
* a caller never waits on a listener that threw. A caller that asks for `name` also gets
|
|
12
|
+
* Pi's session name, because `/team join <label>` is the one door that means "call me
|
|
13
|
+
* this from now on"; the footer draws the roster label after every join either way, so a
|
|
14
|
+
* plain join changes the name on the bar without renaming the session.
|
|
15
|
+
*
|
|
16
|
+
* The claim is the answer, not the result: the bus starts and joins asynchronously, so
|
|
17
|
+
* this answers with the promise and stays claimed while it runs — which is what keeps
|
|
18
|
+
* the caller's fallback from settling a join that is still happening.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -21,20 +27,22 @@ export function registerJoinRequests(pi: ExtensionAPI, bus: SessionBus): void {
|
|
|
21
27
|
pi.events.on(TEAM_JOIN_REQUEST, (data) => {
|
|
22
28
|
const request = data as TeamJoinRequest | undefined;
|
|
23
29
|
if (!request || typeof request.respond !== "function") return;
|
|
24
|
-
|
|
30
|
+
request.respond(answer(request));
|
|
25
31
|
});
|
|
26
32
|
|
|
27
|
-
async function answer(request: TeamJoinRequest): Promise<
|
|
33
|
+
async function answer(request: TeamJoinRequest): Promise<JoinResult> {
|
|
28
34
|
let result: JoinResult;
|
|
29
35
|
try {
|
|
30
36
|
await bus.start(request.ctx);
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
37
|
+
// `name` is this wrapper's business, not the bus's: the record's label and Pi's
|
|
38
|
+
// session name are two facts, and only a caller that asked gets both changed.
|
|
39
|
+
const { name, ...input } = request.input;
|
|
40
|
+
const joined = await bus.join(input);
|
|
41
|
+
if (name === true && request.input.label !== undefined) pi.setSessionName(joined.label);
|
|
34
42
|
result = { ok: true, label: joined.label, watching: joined.watching };
|
|
35
43
|
} catch (error) {
|
|
36
44
|
result = { ok: false, error: messageOf(error) };
|
|
37
45
|
}
|
|
38
|
-
|
|
46
|
+
return result;
|
|
39
47
|
}
|
|
40
48
|
}
|
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { RepeatTimer } from "../../lib/timer.ts";
|
|
15
|
-
import type { BusMessage } from "./types.ts";
|
|
15
|
+
import type { BusMessage, InboxPeek } from "./types.ts";
|
|
16
16
|
|
|
17
17
|
export interface MailWatchOptions {
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* This session's inbox, read-only: up to a page of mail whose ids are not `known`, and
|
|
20
|
+
* the ids of every message still there. Nothing is consumed or marked.
|
|
21
|
+
*/
|
|
22
|
+
peek(known: ReadonlySet<string>): Promise<InboxPeek>;
|
|
20
23
|
/** The repeating timer behind the poll. */
|
|
21
24
|
interval: RepeatTimer;
|
|
22
25
|
/** Poll interval; 0 disables the ticker (tests). */
|
|
@@ -54,7 +57,10 @@ export function createMailWatch(options: MailWatchOptions): MailWatch {
|
|
|
54
57
|
let wake = false;
|
|
55
58
|
let stopTicker: (() => void) | undefined;
|
|
56
59
|
let pendingMail: BusMessage[] = [];
|
|
57
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Ids already queued, reported, or consumed — pruned by what is still on disk, so it
|
|
62
|
+
* holds exactly the announced mail an `inbox` call has not removed yet.
|
|
63
|
+
*/
|
|
58
64
|
let reported = new Set<string>();
|
|
59
65
|
let onWake: (() => void) | undefined;
|
|
60
66
|
|
|
@@ -68,24 +74,27 @@ export function createMailWatch(options: MailWatchOptions): MailWatch {
|
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/**
|
|
71
|
-
* Queue what
|
|
72
|
-
*
|
|
77
|
+
* Queue what this watch has not announced, once, and wake a turn when this session
|
|
78
|
+
* asked for one.
|
|
73
79
|
*
|
|
74
|
-
* The
|
|
75
|
-
* the
|
|
76
|
-
*
|
|
80
|
+
* The queue and the memory are pruned by what is still on disk, so `reported` is
|
|
81
|
+
* bounded by the inbox rather than by a count: an id that leaves the inbox leaves the
|
|
82
|
+
* set, and no id still on disk can be forgotten while it is there. A count was how a
|
|
83
|
+
* backlog larger than one read made the watch forget an id it had already announced and
|
|
84
|
+
* announce it again.
|
|
77
85
|
*/
|
|
78
86
|
async function pollOnce(): Promise<void> {
|
|
79
87
|
if (!armed) return;
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
//
|
|
88
|
+
const known = new Set(reported);
|
|
89
|
+
for (const message of pendingMail) known.add(message.id);
|
|
90
|
+
const { mail, unread } = await peek(known);
|
|
91
|
+
// The read names everything still in the inbox, which is a different question from
|
|
92
|
+
// what one page covered: a message that expired or was consumed leaves the queue and
|
|
93
|
+
// the memory with its file.
|
|
94
|
+
for (const id of reported) if (!unread.has(id)) reported.delete(id);
|
|
84
95
|
pendingMail = pendingMail.filter((message) => unread.has(message.id));
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (fresh.length === 0) return;
|
|
88
|
-
pendingMail = [...pendingMail, ...fresh];
|
|
96
|
+
if (mail.length === 0) return;
|
|
97
|
+
pendingMail = [...pendingMail, ...mail];
|
|
89
98
|
if (wake) onWake?.();
|
|
90
99
|
}
|
|
91
100
|
|
|
@@ -65,16 +65,28 @@ export function normalizeLimit(raw: unknown): number {
|
|
|
65
65
|
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Repository-relative, posix-normalized paths, or an error.
|
|
70
|
+
*
|
|
71
|
+
* A path that resolves to the repository root is handed on in the caller's own spelling
|
|
72
|
+
* rather than refused here: what it resolves to is the whole repository, and that
|
|
73
|
+
* refusal, with the sentence it is worded in, belongs to `resolve.ts` — one rule, one
|
|
74
|
+
* wording, and one module that owns both.
|
|
75
|
+
*/
|
|
69
76
|
export function normalizePaths(repoRoot: string, cwd: string, raw: unknown): { paths: string[] } | { error: string } {
|
|
70
77
|
if (!Array.isArray(raw) || raw.length === 0) return { error: "paths must contain at least one path." };
|
|
71
78
|
if (raw.length > MAX_PATHS) return { error: `at most ${MAX_PATHS} paths are allowed.` };
|
|
72
79
|
const paths: string[] = [];
|
|
73
80
|
for (const entry of raw) {
|
|
74
81
|
if (typeof entry !== "string" || entry.trim() === "") return { error: "every path must be a non-empty string." };
|
|
75
|
-
const
|
|
82
|
+
const given = entry.trim();
|
|
83
|
+
const absolute = resolve(cwd, given);
|
|
76
84
|
const rel = relative(repoRoot, absolute);
|
|
77
|
-
if (rel === ""
|
|
85
|
+
if (rel === "") {
|
|
86
|
+
if (!paths.includes(given)) paths.push(given);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
78
90
|
return { error: `path "${entry}" is outside the repository.` };
|
|
79
91
|
}
|
|
80
92
|
const normalized = rel.split(sep).join("/");
|
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
* spelling inside the repository and the file it names are one claim, and a path
|
|
7
7
|
* that leaves the repository is refused before anything is written.
|
|
8
8
|
*
|
|
9
|
+
* The caller hands in paths that are already relative to the repository (the
|
|
10
|
+
* spelling rule is `protocol.ts`'s), so what is decided here is what they *resolve*
|
|
11
|
+
* to: the root itself, a target outside it, or a claim.
|
|
12
|
+
*
|
|
9
13
|
* `lib/path.ts` owns the containment predicates; this owns the claim's shape.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
|
-
import {
|
|
16
|
+
import { join, relative, sep } from "node:path";
|
|
13
17
|
import { isInside, realPathOfNearest } from "../../lib/path.ts";
|
|
14
18
|
|
|
15
19
|
/** A path this module declined, with the reason a caller can print. */
|
|
@@ -40,9 +44,6 @@ const ROOT_ITSELF = "is the repository, not a path in it";
|
|
|
40
44
|
/** What a path outside the root resolves to. */
|
|
41
45
|
const OUTSIDE = "does not resolve to a file in the repository";
|
|
42
46
|
|
|
43
|
-
/** What an absolute path is refused for: it is not the claim's to place. */
|
|
44
|
-
const ABSOLUTE = "is absolute; a claim is relative to the repository root";
|
|
45
|
-
|
|
46
47
|
/**
|
|
47
48
|
* Resolve `paths` against `root`, or refuse the first one that is not in it.
|
|
48
49
|
*
|
|
@@ -53,9 +54,6 @@ export function resolveClaimPaths(root: string, paths: readonly string[]): Resol
|
|
|
53
54
|
const realRoot = realPathOfNearest(root);
|
|
54
55
|
const canonical: string[] = [];
|
|
55
56
|
for (const path of paths) {
|
|
56
|
-
// `join` would re-root an absolute path under the repository, which is not what
|
|
57
|
-
// a caller meant: refuse it rather than claim a directory it never named.
|
|
58
|
-
if (isAbsolute(path)) return { error: { path, reason: ABSOLUTE } };
|
|
59
57
|
const target = realPathOfNearest(join(root, path));
|
|
60
58
|
const rel = relative(realRoot, target);
|
|
61
59
|
if (rel === "") return { error: { path, reason: ROOT_ITSELF } };
|
|
@@ -7,39 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
11
10
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
12
11
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
13
|
-
import {
|
|
12
|
+
import type { TailResultInput, TailSection } from "../../lib/tui/tool.ts";
|
|
14
13
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
15
14
|
import { type BusRow, busRowLine, count, sessionRows } from "./rows.ts";
|
|
16
15
|
import type { SessionDetails } from "./types.ts";
|
|
17
16
|
|
|
18
|
-
/**
|
|
19
|
-
* The transcript block for an expanded `session` result: the shared tail block
|
|
20
|
-
* over the board's rows, one section per entity group.
|
|
21
|
-
*/
|
|
22
|
-
export class SessionResult implements Component {
|
|
23
|
-
private readonly view: TailResult<BusRow>;
|
|
24
|
-
|
|
25
|
-
constructor(input: TailResultInput<BusRow>, theme: Theme) {
|
|
26
|
-
this.view = new TailResult(input, theme);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Update in place so the transcript can reuse this component across renders. */
|
|
30
|
-
setInput(input: TailResultInput<BusRow>, theme: Theme): void {
|
|
31
|
-
this.view.setInput(input, theme);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
invalidate(): void {
|
|
35
|
-
this.view.invalidate();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
render(width: number): string[] {
|
|
39
|
-
return this.view.render(width);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
17
|
/** One section of the expanded result. */
|
|
44
18
|
function section(rows: BusRow[], label?: string): TailSection<BusRow> {
|
|
45
19
|
return { label, rows, more: 0, rowLine: busRowLine };
|