@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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review's two write paths, over the plan store.
|
|
3
|
+
*
|
|
4
|
+
* An owner's save decides the review and returns to Pi; a reviewer's post only writes,
|
|
5
|
+
* under the name its link has bound. Both write markers through the one codec
|
|
6
|
+
* (`feedback.ts`), both refuse a file that changed under the page, and neither touches
|
|
7
|
+
* another writer's markers — the store replaces exactly the writer it is handed.
|
|
8
|
+
*
|
|
9
|
+
* Splitting them out of `web.ts` is what leaves that module the session and its sockets:
|
|
10
|
+
* every request that writes the plan comes through here, so a change to the marker
|
|
11
|
+
* grammar or to the refusal wording has one file to land in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { claimAuthor, normalizeAuthor, stripFeedback, type PlanAnnotation } from "./feedback.ts";
|
|
15
|
+
import type { PlanStore, StoredPlan } from "./plans.ts";
|
|
16
|
+
import type { ReviewCaller } from "./types.ts";
|
|
17
|
+
import type { SaveResult } from "./web-router.ts";
|
|
18
|
+
|
|
19
|
+
/** What a write path reads: the store, and the anchor a save is validated against. */
|
|
20
|
+
export interface WriteDeps {
|
|
21
|
+
/** The plan store the markers are written through. */
|
|
22
|
+
store: PlanStore;
|
|
23
|
+
/** The directory the store resolves its paths against. */
|
|
24
|
+
cwd: string;
|
|
25
|
+
/** The plan's path, as the store knows it. */
|
|
26
|
+
path: string;
|
|
27
|
+
/**
|
|
28
|
+
* The stripped text the page rendered. A file whose body no longer matches is a
|
|
29
|
+
* `409`: the comments would otherwise land beside text nobody read.
|
|
30
|
+
*/
|
|
31
|
+
pageText(): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An owner's save: the plan as it was written, or why it was refused. */
|
|
35
|
+
export type OwnerWrite = { ok: true; plan: StoredPlan } | { ok: false; status: number; message: string };
|
|
36
|
+
|
|
37
|
+
/** Every refusal both paths share, before anyone's markers are touched. */
|
|
38
|
+
const GONE = { ok: false, status: 404, message: "The plan file is gone." } as const;
|
|
39
|
+
const MOVED = {
|
|
40
|
+
ok: false,
|
|
41
|
+
status: 409,
|
|
42
|
+
message: "The plan changed on disk; reload the page and review again.",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
/** The file as it is now, when it still matches what the page rendered. */
|
|
46
|
+
async function verified(
|
|
47
|
+
deps: WriteDeps,
|
|
48
|
+
): Promise<{ ok: true; plan: StoredPlan } | { ok: false; status: number; message: string }> {
|
|
49
|
+
const current = await deps.store.read(deps.cwd, deps.path);
|
|
50
|
+
if (!current) return GONE;
|
|
51
|
+
if (stripFeedback(current.content) !== deps.pageText()) return MOVED;
|
|
52
|
+
return { ok: true, plan: current };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Save the owner's comments and report the plan they were written into.
|
|
57
|
+
*
|
|
58
|
+
* The caller decides what a save means (it ends the review); this path only writes, so
|
|
59
|
+
* the decision and the write cannot disagree about which file was saved.
|
|
60
|
+
*/
|
|
61
|
+
export async function saveOwnerAnnotations(
|
|
62
|
+
deps: WriteDeps,
|
|
63
|
+
annotations: readonly PlanAnnotation[],
|
|
64
|
+
): Promise<OwnerWrite> {
|
|
65
|
+
const check = await verified(deps);
|
|
66
|
+
if (!check.ok) return check;
|
|
67
|
+
try {
|
|
68
|
+
const plan = await deps.store.saveFeedback(deps.cwd, deps.path, [...annotations]);
|
|
69
|
+
return { ok: true, plan };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a reviewer's post additionally needs: who posted, and which names are taken. */
|
|
76
|
+
export interface ReviewerWriteDeps extends WriteDeps {
|
|
77
|
+
/** The caller the link names; its `name` is the one the link has bound, if any. */
|
|
78
|
+
caller: ReviewCaller;
|
|
79
|
+
/** Names other live links hold, so a first post cannot claim one of them. */
|
|
80
|
+
taken: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A reviewer's post: the name it stored under, so a page can adopt it. */
|
|
84
|
+
export type ReviewerWrite = { save: SaveResult; count: number };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write one reviewer's comments, signed and scoped to that link.
|
|
88
|
+
*
|
|
89
|
+
* The link, not the payload, decides whose markers this replaces: a page that signs
|
|
90
|
+
* another name is a stale or foreign tab, and re-signing it would move markers that
|
|
91
|
+
* belong to whoever picked that name. Only that writer's annotations are written, so a
|
|
92
|
+
* post cannot disturb the owner's notes or a second reviewer's.
|
|
93
|
+
*/
|
|
94
|
+
export async function postReviewerAnnotations(
|
|
95
|
+
deps: ReviewerWriteDeps,
|
|
96
|
+
annotations: readonly PlanAnnotation[],
|
|
97
|
+
): Promise<ReviewerWrite> {
|
|
98
|
+
const check = await verified(deps);
|
|
99
|
+
if (!check.ok) return { save: check, count: 0 };
|
|
100
|
+
const claim = claimAuthor({ bound: deps.caller.name, taken: deps.taken }, annotations);
|
|
101
|
+
if (!("name" in claim)) return { save: { ok: false, status: claim.status, message: claim.message }, count: 0 };
|
|
102
|
+
const own = annotations.filter((annotation) => normalizeAuthor(annotation.author) === claim.name);
|
|
103
|
+
try {
|
|
104
|
+
await deps.store.saveFeedback(deps.cwd, deps.path, own, { author: claim.name });
|
|
105
|
+
return { save: { ok: true, author: claim.name }, count: own.length };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return {
|
|
108
|
+
save: { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) },
|
|
109
|
+
count: 0,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
package/extensions/plan/web.ts
CHANGED
|
@@ -10,37 +10,26 @@
|
|
|
10
10
|
* the socket, and it takes injected seams so tests can run one on `127.0.0.1:0`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { networkInterfaces } from "node:os";
|
|
14
|
-
import { basename } from "node:path";
|
|
15
13
|
import { openInBrowser } from "../../lib/browser.ts";
|
|
16
14
|
import {
|
|
15
|
+
type HttpMount,
|
|
17
16
|
jsonHeaders,
|
|
18
17
|
type LoopbackRequest,
|
|
19
18
|
type LoopbackReply,
|
|
20
|
-
type
|
|
19
|
+
type MountOutcome,
|
|
20
|
+
type MountSource,
|
|
21
21
|
parseRequestUrl,
|
|
22
|
-
serveLoopback,
|
|
23
22
|
type ServeResponse,
|
|
24
23
|
} from "../../lib/http/index.ts";
|
|
25
|
-
import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
|
|
26
24
|
import { type ArmTimer, defaultInterval, defaultTimer, type RepeatTimer } from "../../lib/timer.ts";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
MAX_COMMENT_LENGTH,
|
|
32
|
-
MAX_QUOTE_LENGTH,
|
|
33
|
-
normalizeAuthor,
|
|
34
|
-
readFeedback,
|
|
35
|
-
stripFeedback,
|
|
36
|
-
type PlanAnnotation,
|
|
37
|
-
} from "./feedback.ts";
|
|
38
|
-
import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
|
|
39
|
-
import { changedSections, planDigest, type SectionDigest } from "./plan-text.ts";
|
|
40
|
-
import { extractPlanSteps } from "./steps.ts";
|
|
25
|
+
import { mintToken } from "../../lib/http/index.ts";
|
|
26
|
+
import { stripFeedback, type PlanAnnotation } from "./feedback.ts";
|
|
27
|
+
import type { PlanStore, StoredPlan } from "./plans.ts";
|
|
28
|
+
import type { SectionDigest } from "./plan-text.ts";
|
|
41
29
|
import type { ReviewCaller } from "./types.ts";
|
|
42
30
|
import { handleReviewRequest, type ReviewContext, type SaveResult } from "./web-router.ts";
|
|
43
|
-
import type
|
|
31
|
+
import { reviewPageData, type ReviewPageData } from "./web-payload.ts";
|
|
32
|
+
import { postReviewerAnnotations, saveOwnerAnnotations } from "./web-write.ts";
|
|
44
33
|
import { createShareSession, type ShareSession } from "./web-share-session.ts";
|
|
45
34
|
import { createStreams } from "./web-streams.ts";
|
|
46
35
|
import { createTokens } from "./web-tokens.ts";
|
|
@@ -100,8 +89,10 @@ export interface PlanWebReviewDeps {
|
|
|
100
89
|
open?: (url: string) => void;
|
|
101
90
|
/** Override the session token (tests). */
|
|
102
91
|
token?: () => string;
|
|
103
|
-
/**
|
|
104
|
-
|
|
92
|
+
/** Where the review's prefix comes from: the session's server, or a suite's own. */
|
|
93
|
+
mount: MountSource;
|
|
94
|
+
/** The Pi session asking, so the prefix lands on this session's socket. */
|
|
95
|
+
session: () => string;
|
|
105
96
|
/** Override the closed-tab grace; the default leaves a reload time to reconnect. */
|
|
106
97
|
dropGraceMs?: number;
|
|
107
98
|
/**
|
|
@@ -140,14 +131,22 @@ const DROP_GRACE_MS = 1500;
|
|
|
140
131
|
*/
|
|
141
132
|
const PLAN_POLL_MS = 1000;
|
|
142
133
|
|
|
143
|
-
|
|
134
|
+
/**
|
|
135
|
+
* The deps a caller that injects its own review leaves out.
|
|
136
|
+
*
|
|
137
|
+
* A review needs a place on the session's server, so a caller with no wiring says so
|
|
138
|
+
* rather than binding a socket of its own: the start is refused with a sentence.
|
|
139
|
+
*/
|
|
140
|
+
export const NO_SERVER_DEPS: PlanWebReviewDeps = {
|
|
141
|
+
mount: async () => ({ kind: "refused", reason: "this review has no server wired" }),
|
|
142
|
+
session: () => "",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps): PlanWebReview {
|
|
144
146
|
const sessions = new Set<PlanReviewSession>();
|
|
145
147
|
// What each running review can do about sharing; the session itself stays the
|
|
146
148
|
// four things a caller awaits and closes.
|
|
147
149
|
const shares = new Map<PlanReviewSession, ShareSession>();
|
|
148
|
-
// Last bound port per plan path, so the same plan keeps one origin across
|
|
149
|
-
// review turns (and its saved browser theme with it).
|
|
150
|
-
const ports = new Map<string, number>();
|
|
151
150
|
|
|
152
151
|
const start = async (
|
|
153
152
|
cwd: string,
|
|
@@ -164,8 +163,8 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
164
163
|
// the name each link has bound.
|
|
165
164
|
const tokens = createTokens(deps.token);
|
|
166
165
|
const token = tokens.mint("owner");
|
|
167
|
-
// The
|
|
168
|
-
|
|
166
|
+
// The review's own path segment: short, opaque, and stable for this review's life.
|
|
167
|
+
const id = mintToken().slice(0, 8);
|
|
169
168
|
let settled = false;
|
|
170
169
|
let resolveDone!: (result: PlanReviewResult) => void;
|
|
171
170
|
const done = new Promise<PlanReviewResult>((resolve) => {
|
|
@@ -239,65 +238,21 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
239
238
|
if (!current) return;
|
|
240
239
|
if (stripFeedback(current.content) !== pageText) notifyPlanChange();
|
|
241
240
|
};
|
|
242
|
-
stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
|
|
243
241
|
|
|
244
242
|
const page = async (caller: ReviewCaller): Promise<ReviewPageData> => {
|
|
245
|
-
const { role } = caller;
|
|
246
243
|
const stored = await refreshPageText();
|
|
247
|
-
// The
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const marks = changedSections(options.baseline, current);
|
|
256
|
-
const outline = marks.flatMap(({ section, changed }) =>
|
|
257
|
-
section.title === ""
|
|
258
|
-
? []
|
|
259
|
-
: [
|
|
260
|
-
{
|
|
261
|
-
id: `s-${section.line}`,
|
|
262
|
-
line: section.line,
|
|
263
|
-
level: section.level,
|
|
264
|
-
text: section.title,
|
|
265
|
-
...(changed ? { changed: true } : {}),
|
|
266
|
-
},
|
|
267
|
-
],
|
|
268
|
-
);
|
|
269
|
-
// The same step list approval seeds and set_plan_steps indexes, so the page's
|
|
270
|
-
// progress cannot disagree with the todo list.
|
|
271
|
-
const stepList = extractPlanSteps(current);
|
|
272
|
-
// Highlight code blocks once, at the boundary: the client copies only the
|
|
273
|
-
// tok-* spans out of the server-escaped HTML.
|
|
274
|
-
const code: Record<string, string[]> = {};
|
|
275
|
-
for (const block of blocks) {
|
|
276
|
-
if (block.kind !== "code") continue;
|
|
277
|
-
const language = fenceLanguage(fenceInfo(block.lines[0] ?? ""));
|
|
278
|
-
if (language) code[String(block.start)] = highlightLines(codeBody(block), language);
|
|
279
|
-
}
|
|
280
|
-
return {
|
|
281
|
-
// The document's own H1 reads better than the file-name slug, and the
|
|
282
|
-
// slug is still the fallback for a plan that starts with something else.
|
|
283
|
-
title: marks.find((mark) => mark.section.level === 1)?.section.title || planTitle(basename(plan.path)),
|
|
284
|
-
blocks,
|
|
285
|
-
annotations: read.annotations,
|
|
286
|
-
outline,
|
|
287
|
-
steps: {
|
|
288
|
-
done: stepList.filter((step) => step.status === "completed").length,
|
|
289
|
-
total: stepList.length,
|
|
290
|
-
},
|
|
291
|
-
code,
|
|
292
|
-
limits: { comments: MAX_ANNOTATIONS, comment: MAX_COMMENT_LENGTH, quote: MAX_QUOTE_LENGTH },
|
|
244
|
+
// The island is one function of what the page renders: the text, the baseline
|
|
245
|
+
// the previous review left, and who is asking.
|
|
246
|
+
return reviewPageData({
|
|
247
|
+
base,
|
|
248
|
+
path: plan.path,
|
|
249
|
+
content: stored.content,
|
|
250
|
+
baseline: options.baseline,
|
|
251
|
+
caller,
|
|
293
252
|
token,
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
...(caller.name === undefined ? {} : { author: caller.name }),
|
|
298
|
-
canApprove: role === "owner" && canApprove,
|
|
299
|
-
share: role === "owner" ? shareSession.state() : undefined,
|
|
300
|
-
};
|
|
253
|
+
canApprove,
|
|
254
|
+
share: () => shareSession.state(),
|
|
255
|
+
});
|
|
301
256
|
};
|
|
302
257
|
|
|
303
258
|
/**
|
|
@@ -309,43 +264,48 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
309
264
|
*/
|
|
310
265
|
const save = async (annotations: PlanAnnotation[], caller: ReviewCaller): Promise<SaveResult> => {
|
|
311
266
|
const { role } = caller;
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
try {
|
|
318
|
-
if (role === "owner") {
|
|
319
|
-
savedPlan = await store.saveFeedback(cwd, plan.path, annotations);
|
|
320
|
-
streams.broadcast("comment");
|
|
321
|
-
return { ok: true };
|
|
322
|
-
}
|
|
323
|
-
// The link, not the payload, decides whose markers this post replaces: the name
|
|
324
|
-
// is bound to the token on its first write, and a page that signs another one
|
|
325
|
-
// is a stale tab rather than an authorized writer.
|
|
326
|
-
const claim = claimAuthor({ bound: caller.name, taken: tokens.namesExcept(caller) }, annotations);
|
|
327
|
-
if (!("name" in claim)) return { ok: false, status: claim.status, message: claim.message };
|
|
328
|
-
const own = annotations.filter((annotation) => normalizeAuthor(annotation.author) === claim.name);
|
|
329
|
-
// The saved plan is deliberately not recorded here: a post is not a result
|
|
330
|
-
// the session can return, so there is nothing for a later flush to resolve.
|
|
331
|
-
await store.saveFeedback(cwd, plan.path, own, { author: claim.name });
|
|
332
|
-
caller.name ??= claim.name;
|
|
267
|
+
const write = { store, cwd, path: plan.path, pageText: () => pageText };
|
|
268
|
+
if (role === "owner") {
|
|
269
|
+
const saved = await saveOwnerAnnotations(write, annotations);
|
|
270
|
+
if (!saved.ok) return saved;
|
|
271
|
+
savedPlan = saved.plan;
|
|
333
272
|
streams.broadcast("comment");
|
|
334
|
-
|
|
335
|
-
return { ok: true, author: claim.name };
|
|
336
|
-
} catch (error) {
|
|
337
|
-
return { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) };
|
|
273
|
+
return { ok: true };
|
|
338
274
|
}
|
|
275
|
+
// The saved plan is deliberately not recorded for a reviewer: a post is not a
|
|
276
|
+
// result the session can return, so there is nothing for a later flush to resolve.
|
|
277
|
+
const posted = await postReviewerAnnotations(
|
|
278
|
+
{ ...write, caller, taken: tokens.namesExcept(caller) },
|
|
279
|
+
annotations,
|
|
280
|
+
);
|
|
281
|
+
if (!posted.save.ok) return posted.save;
|
|
282
|
+
caller.name ??= posted.save.author;
|
|
283
|
+
streams.broadcast("comment");
|
|
284
|
+
// The page is told the name its link stored, and the terminal is told a peer wrote.
|
|
285
|
+
if (posted.count > 0) options.onReviewerComment?.(posted.save.author ?? "", posted.count);
|
|
286
|
+
return posted.save;
|
|
339
287
|
};
|
|
340
288
|
|
|
289
|
+
// One review, one prefix on the session's server: the id is the review's, so two
|
|
290
|
+
// reviews of two plans (or one plan twice) never share a namespace, and the origin
|
|
291
|
+
// is the session's single port.
|
|
292
|
+
const base = `/plan/${id}`;
|
|
293
|
+
const outcome: MountOutcome = await deps.mount({
|
|
294
|
+
session: deps.session(),
|
|
295
|
+
prefix: base,
|
|
296
|
+
maxBodyBytes: bodyLimit,
|
|
297
|
+
handler: handleRequest,
|
|
298
|
+
});
|
|
299
|
+
if (outcome.kind === "refused") return undefined;
|
|
300
|
+
const mount: HttpMount = outcome.mount;
|
|
301
|
+
const port = mount.port;
|
|
302
|
+
|
|
341
303
|
// The second listener, its reviewer token, and the state the page and
|
|
342
304
|
// `/plan share` read. It is handed the request handler, the role map, and the
|
|
343
305
|
// frame hooks, so sharing stays one concern instead of five fields here.
|
|
344
306
|
const shareSession = createShareSession({
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
ownerPort: () => currentPort,
|
|
348
|
-
networkInterfaces: deps.networkInterfaces ?? networkInterfaces,
|
|
307
|
+
mount,
|
|
308
|
+
base,
|
|
349
309
|
mintReviewer: () => tokens.mint("reviewer"),
|
|
350
310
|
revokeReviewer: (value) => tokens.revoke(value),
|
|
351
311
|
nameOf: (value) => tokens.get(value)?.name,
|
|
@@ -412,24 +372,11 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
412
372
|
};
|
|
413
373
|
}
|
|
414
374
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
server = await serveLoopback({
|
|
419
|
-
ports: preferred === 0 ? [0] : [preferred, 0],
|
|
420
|
-
maxBodyBytes: bodyLimit,
|
|
421
|
-
handler: handleRequest,
|
|
422
|
-
});
|
|
423
|
-
} catch {
|
|
424
|
-
// The remembered port may have been taken between turns; the fallback above
|
|
425
|
-
// covers that, so reaching here means neither port answered.
|
|
426
|
-
return undefined;
|
|
427
|
-
}
|
|
428
|
-
const port = server.port;
|
|
429
|
-
ports.set(plan.path, port);
|
|
430
|
-
currentPort = port;
|
|
375
|
+
// Armed only now: a review whose listener never bound has no page to keep current,
|
|
376
|
+
// and an interval started for it would outlive the session that failed to start.
|
|
377
|
+
stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
|
|
431
378
|
|
|
432
|
-
const url =
|
|
379
|
+
const url = `${mount.origin}${base}/?token=${token}`;
|
|
433
380
|
let closed = false;
|
|
434
381
|
let abortHandler: (() => void) | undefined;
|
|
435
382
|
const close = async (): Promise<void> => {
|
|
@@ -444,7 +391,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
|
|
|
444
391
|
stopTicker();
|
|
445
392
|
streams.clear();
|
|
446
393
|
await shareSession.close();
|
|
447
|
-
await
|
|
394
|
+
await mount.release();
|
|
448
395
|
};
|
|
449
396
|
|
|
450
397
|
const session: PlanReviewSession = { url, port, done, close };
|
|
@@ -12,6 +12,7 @@
|
|
|
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 { planModeRefusal } from "../../../lib/plan-entry.ts";
|
|
15
16
|
import { isPlanModeEnabled } from "../../../lib/plan-state.ts";
|
|
16
17
|
import { withRailsSuppressed } from "../../../lib/rails.ts";
|
|
17
18
|
import {
|
|
@@ -75,7 +76,7 @@ async function loadReview(runtime: RewindRuntime, ctx: ExtensionCommandContext):
|
|
|
75
76
|
return {
|
|
76
77
|
ok: false,
|
|
77
78
|
kind: "warning",
|
|
78
|
-
message: "
|
|
79
|
+
message: planModeRefusal("reviewing and reverting its changes"),
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's one port: the `serve` section of the shared `bytefly.json` (global
|
|
3
|
+
* then project, project wins).
|
|
4
|
+
*
|
|
5
|
+
* `0` — the default — takes one free port for the session and remembers it, which
|
|
6
|
+
* is what a session that never bookmarks wants. A positive value pins the session's
|
|
7
|
+
* server, so every page it serves keeps the address a bookmark already points at.
|
|
8
|
+
* A pinned port that is taken fails the start rather than moving the pages out from
|
|
9
|
+
* under the bookmark.
|
|
10
|
+
*
|
|
11
|
+
* Pin it in the project document when more than one repository runs pages on the
|
|
12
|
+
* machine: a global value is one port for every session.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { clampInteger, loadConfigSection } from "../../lib/config.ts";
|
|
16
|
+
|
|
17
|
+
/** The `serve` section. */
|
|
18
|
+
export interface ServeConfig {
|
|
19
|
+
/** TCP port on `127.0.0.1`; `0` picks one for the session, a positive value pins it. */
|
|
20
|
+
port: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_SERVE_CONFIG: ServeConfig = { port: 0 };
|
|
24
|
+
|
|
25
|
+
/** Validate and clamp a raw config object over the defaults. */
|
|
26
|
+
export function normalizeServeConfig(
|
|
27
|
+
raw: Record<string, unknown> | undefined,
|
|
28
|
+
base: ServeConfig = DEFAULT_SERVE_CONFIG,
|
|
29
|
+
): ServeConfig {
|
|
30
|
+
if (!raw) return base;
|
|
31
|
+
return { port: clampInteger(raw.port, base.port, 0, 65_535) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Load the effective config for a working directory. */
|
|
35
|
+
export function loadServeConfig(cwd: string): ServeConfig {
|
|
36
|
+
return loadConfigSection(cwd, "serve", DEFAULT_SERVE_CONFIG, normalizeServeConfig);
|
|
37
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* serve — the one loopback server a session serves its pages on.
|
|
3
|
+
*
|
|
4
|
+
* It registers one bus listener and closes what it opened; it draws no page of its
|
|
5
|
+
* own. `plan`, `board`, and `file-browse` ask for a path prefix on
|
|
6
|
+
* {@link HTTP_MOUNT_CHANNEL} and the registry answers with the session's origin, so
|
|
7
|
+
* a session has one port, one close path, and one chip instead of one socket per
|
|
8
|
+
* extension. Binding happens on the first page that asks, never in the factory.
|
|
9
|
+
*
|
|
10
|
+
* Load with: pi --extension ./extensions/serve
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { networkInterfaces } from "node:os";
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
16
|
+
import { HTTP_MOUNT_CHANNEL, type MountRequest } from "../../lib/http/index.ts";
|
|
17
|
+
import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
18
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
19
|
+
import { loadServeConfig } from "./config.ts";
|
|
20
|
+
import { createServeRegistry } from "./registry.ts";
|
|
21
|
+
|
|
22
|
+
export default function serve(pi: ExtensionAPI): void {
|
|
23
|
+
if (!isExtensionEnabled("serve")) return;
|
|
24
|
+
/** Each session's working directory, so its pin is read from its own config. */
|
|
25
|
+
const cwds = new Map<string, string>();
|
|
26
|
+
/** Each session's context, which is where the port chip is drawn. */
|
|
27
|
+
const contexts = new Map<string, ExtensionContext>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Publish the session's port, or clear the chip with `undefined`. The chip belongs
|
|
31
|
+
* to the server, not to a page: one number, shown while anything is served on it.
|
|
32
|
+
*/
|
|
33
|
+
const setChip = (session: string, port: number | undefined): void => {
|
|
34
|
+
const ctx = contexts.get(session);
|
|
35
|
+
if (ctx === undefined) return;
|
|
36
|
+
try {
|
|
37
|
+
const value =
|
|
38
|
+
port === undefined
|
|
39
|
+
? undefined
|
|
40
|
+
: `${paint(ctx.ui.theme, "ok", GLYPHS.http)} ${paint(ctx.ui.theme, "accent", String(port))}`;
|
|
41
|
+
ctx.ui.setStatus(STATUS_KEYS.http, value);
|
|
42
|
+
} catch {
|
|
43
|
+
// A host without a status line is not a reason to keep a page from starting.
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const registry = createServeRegistry({
|
|
48
|
+
portFor: (session) => loadServeConfig(cwds.get(session) ?? process.cwd()).port,
|
|
49
|
+
interfaces: () => networkInterfaces(),
|
|
50
|
+
onPort: setChip,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
pi.events.on(HTTP_MOUNT_CHANNEL, (data) => {
|
|
54
|
+
const request = data as Partial<MountRequest> | undefined;
|
|
55
|
+
if (request?.spec === undefined) return;
|
|
56
|
+
registry.handle(request as MountRequest);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
60
|
+
const id = ctx.sessionManager.getSessionId();
|
|
61
|
+
cwds.set(id, ctx.cwd);
|
|
62
|
+
contexts.set(id, ctx);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
66
|
+
const id = ctx.sessionManager.getSessionId();
|
|
67
|
+
await registry.closeSession(id);
|
|
68
|
+
cwds.delete(id);
|
|
69
|
+
contexts.delete(id);
|
|
70
|
+
});
|
|
71
|
+
}
|