@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 +61 -45
- package/README.zh-CN.md +54 -42
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
- package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
- package/docs/extensions/plan.md +976 -0
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +11 -21
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
- package/extensions/file-browse/live/rev.ts +42 -0
- package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/view/chrome.ts +83 -0
- package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
- package/extensions/file-browse/view/diff-html.ts +94 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +77 -21
- package/extensions/plan/feedback.ts +409 -63
- package/extensions/plan/index.ts +7 -5
- package/extensions/plan/plan-index.ts +44 -10
- package/extensions/plan/plan-text.ts +51 -24
- package/extensions/plan/plans.ts +162 -57
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +51 -95
- package/extensions/plan/runtime.ts +22 -10
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +63 -95
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +13 -19
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +29 -15
- package/extensions/plan/types.ts +29 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +122 -178
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +96 -17
- package/extensions/plan/web-router.ts +109 -47
- package/extensions/plan/web-share-session.ts +191 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +245 -151
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +113 -0
- package/extensions/session-bus/format.ts +101 -0
- package/extensions/session-bus/index.ts +75 -0
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +86 -0
- package/extensions/session-bus/overlap.ts +36 -0
- package/extensions/session-bus/protocol.ts +104 -0
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +208 -0
- package/extensions/session-bus/rows.ts +171 -0
- package/extensions/session-bus/runtime.ts +466 -0
- package/extensions/session-bus/store.ts +365 -0
- package/extensions/session-bus/tools.ts +192 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/watch/index.ts +15 -9
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
- package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/guard.ts +3 -5
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +12 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/router.ts +40 -13
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +29 -13
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/policy.ts +12 -2
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +7 -9
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/glyphs.ts +2 -0
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +6 -1
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +27 -14
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +77 -51
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -467
- package/extensions/file-browse/server.ts +0 -134
- package/extensions/plan/README.md +0 -709
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/outbound.ts +0 -100
- package/extensions/wechat/qrcode.ts +0 -46
- package/skills/changelog/SKILL.md +0 -148
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page pointer: how a second session finds the page a repository already serves.
|
|
3
|
+
*
|
|
4
|
+
* A page is one loopback server owned by one session's process, so nothing about it is
|
|
5
|
+
* discoverable without a note left where the repository's state already lives. That note
|
|
6
|
+
* is `<board>/page.json`: the port, the seat serving it, and a *view* token — never the
|
|
7
|
+
* write token, which stays in the URL the lead's own browser was opened at.
|
|
8
|
+
*
|
|
9
|
+
* Two rules make the file safe to trust:
|
|
10
|
+
*
|
|
11
|
+
* - **Created exclusively**, by hard-linking a temp file onto the name, so two sessions
|
|
12
|
+
* starting at the same instant cannot both believe they are the page: the loser reads
|
|
13
|
+
* the winner's pointer and attaches.
|
|
14
|
+
* - **Believed only while it answers.** A crash leaves the file behind, and the file says
|
|
15
|
+
* nothing about whether the process is alive, so a caller probes the page before it
|
|
16
|
+
* attaches or takes the seat over. `probePage` is that question, with the network as a
|
|
17
|
+
* seam so a test drives it.
|
|
18
|
+
*
|
|
19
|
+
* Pure I/O, no state: one call answers one caller, and the caller owns the path it hands in.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { access, link, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { hasErrorCode } from "../errors.ts";
|
|
25
|
+
|
|
26
|
+
/** The file, beside the board's `cards/` directory. */
|
|
27
|
+
export const PAGE_FILE = "page.json";
|
|
28
|
+
|
|
29
|
+
/** How long a probe waits before it calls the page gone. */
|
|
30
|
+
export const PROBE_MS = 600;
|
|
31
|
+
|
|
32
|
+
/** The port, the seat, and the credential a viewer may hold. */
|
|
33
|
+
export interface PagePointer {
|
|
34
|
+
port: number;
|
|
35
|
+
/** A token that satisfies every read and no write. */
|
|
36
|
+
viewToken: string;
|
|
37
|
+
/** The seat the page writes as. */
|
|
38
|
+
seat: { id?: string; label: string };
|
|
39
|
+
/** When the page started, so a reader can tell a fresh note from a stale one. */
|
|
40
|
+
startedAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The fetch a probe uses: the global one in production, a stub in a test. */
|
|
44
|
+
export type ProbeFetch = (url: string, init: { signal: AbortSignal }) => Promise<{ ok: boolean }>;
|
|
45
|
+
|
|
46
|
+
/** The URL a viewer opens: the page, with the credential that may only read it. */
|
|
47
|
+
export function viewUrlOf(pointer: PagePointer): string {
|
|
48
|
+
return `http://127.0.0.1:${pointer.port}/?token=${pointer.viewToken}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a note is there at all, readable or not.
|
|
53
|
+
*
|
|
54
|
+
* `readPointer` answers nothing for two very different states — no page has ever written a
|
|
55
|
+
* note here, and a note this build cannot read — and a caller that has just failed to
|
|
56
|
+
* create one has to tell them apart: the first is a race with a page that cleared its note
|
|
57
|
+
* and is worth another attempt, the second is a file only its owner can explain.
|
|
58
|
+
*/
|
|
59
|
+
export async function noteExists(dir: string): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
await access(join(dir, PAGE_FILE));
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The pointer written for one board directory, or nothing when there is none to trust. */
|
|
69
|
+
export async function readPointer(dir: string): Promise<PagePointer | undefined> {
|
|
70
|
+
return readPointerAt(join(dir, PAGE_FILE));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The pointer a file holds, by path: the same read, for a note that has been moved aside. */
|
|
74
|
+
async function readPointerAt(path: string): Promise<PagePointer | undefined> {
|
|
75
|
+
let text: string;
|
|
76
|
+
try {
|
|
77
|
+
text = await readFile(path, "utf-8");
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return asPointer(JSON.parse(text));
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Write a pointer for a page this caller has just started.
|
|
90
|
+
*
|
|
91
|
+
* Returns whether it became the file: `false` means another session got there first, and
|
|
92
|
+
* the caller should read that one and attach rather than serve a second page.
|
|
93
|
+
*/
|
|
94
|
+
export async function createPointer(dir: string, pointer: PagePointer): Promise<boolean> {
|
|
95
|
+
const target = join(dir, PAGE_FILE);
|
|
96
|
+
const temp = `${target}.tmp-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
|
|
97
|
+
try {
|
|
98
|
+
await writeFile(temp, `${JSON.stringify(pointer, null, "\t")}\n`, { mode: 0o600 });
|
|
99
|
+
try {
|
|
100
|
+
await link(temp, target);
|
|
101
|
+
return true;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// The name is taken: another session is serving this repository, and this one
|
|
104
|
+
// becomes a viewer of it. Any other failure is the caller's to hear about.
|
|
105
|
+
if (hasErrorCode(error, "EEXIST")) return false;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(temp, { force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Remove the pointer for a page that has stopped.
|
|
115
|
+
*
|
|
116
|
+
* Only a caller that has proved the note is not a live page may clear it: the page that
|
|
117
|
+
* wrote it, on its own way out, or a session that just found it answering nobody. The
|
|
118
|
+
* token is what decides — a restart of the same seat has the same seat and a new token,
|
|
119
|
+
* so a note that no longer names a live page never takes the live one's place away.
|
|
120
|
+
* Returns whether the file was the one named.
|
|
121
|
+
*
|
|
122
|
+
* The note is moved aside before it is removed, and the token is checked again on what was
|
|
123
|
+
* moved. A read followed by an unlink is two instants, and a session that judged the old
|
|
124
|
+
* note dead acts on the first one: if it removed the name, it could delete the note a
|
|
125
|
+
* faster session had written in between, and both would serve a page, only one of them
|
|
126
|
+
* findable. The move is atomic, so the file this call holds is the file that was there;
|
|
127
|
+
* a note that is not the one proved dead goes back where it came from.
|
|
128
|
+
*/
|
|
129
|
+
export async function clearPointer(dir: string, viewToken: string): Promise<boolean> {
|
|
130
|
+
const pointer = await readPointer(dir);
|
|
131
|
+
if (pointer === undefined || pointer.viewToken !== viewToken) return false;
|
|
132
|
+
const target = join(dir, PAGE_FILE);
|
|
133
|
+
const held = `${target}.gone-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
|
|
134
|
+
try {
|
|
135
|
+
await rename(target, held);
|
|
136
|
+
} catch {
|
|
137
|
+
// It left while this call was looking, which is somebody else having cleared it.
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
const moved = await readPointerAt(held);
|
|
141
|
+
if (moved === undefined || moved.viewToken !== viewToken) {
|
|
142
|
+
await putBack(held, target);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
await rm(held, { force: true });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Put a note this call moved aside but did not earn the right to remove back in place.
|
|
151
|
+
*
|
|
152
|
+
* A newer note may have taken the name in the same instant; that one stays, and this one
|
|
153
|
+
* was already superseded by it.
|
|
154
|
+
*/
|
|
155
|
+
async function putBack(held: string, target: string): Promise<void> {
|
|
156
|
+
try {
|
|
157
|
+
await link(held, target);
|
|
158
|
+
} catch {
|
|
159
|
+
// The name is taken again: whatever holds it now is newer than what this call moved.
|
|
160
|
+
}
|
|
161
|
+
await rm(held, { force: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** How many times a caller tries to own the note before it gives up for good. */
|
|
165
|
+
export const NOTE_ATTEMPTS = 3;
|
|
166
|
+
|
|
167
|
+
/** What one `/board` found where the note should be. */
|
|
168
|
+
export type PointerOutcome =
|
|
169
|
+
| { kind: "written"; took?: PagePointer }
|
|
170
|
+
| { kind: "attached"; pointer: PagePointer }
|
|
171
|
+
/** The name is taken by a file this build cannot read: nothing to attach to, nothing to take. */
|
|
172
|
+
| { kind: "unreadable" }
|
|
173
|
+
/** Every attempt lost, and the note there still answers nobody. */
|
|
174
|
+
| { kind: "stale"; pointer: PagePointer }
|
|
175
|
+
/** Every attempt lost to a page that came and went, so there is no note to name. */
|
|
176
|
+
| { kind: "busy" };
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Become the repository's page, or find the one that already is.
|
|
180
|
+
*
|
|
181
|
+
* The note is written exclusively, so the loser of a simultaneous start attaches to the
|
|
182
|
+
* winner rather than serving a second page nobody can find. A note that answers nobody is
|
|
183
|
+
* a page that died between its note and its server — and while it sits there no later
|
|
184
|
+
* `/board` can serve either, so it is taken over once it has been proved dead.
|
|
185
|
+
*
|
|
186
|
+
* More than one attempt, because two sessions reach this point in the same instant and
|
|
187
|
+
* the loser of a *takeover* has to end up a viewer of the page that won: it re-reads and
|
|
188
|
+
* probes again rather than reporting a page it never asked about. A failure here names
|
|
189
|
+
* the file, because "another session is serving" is a thing this function can see and
|
|
190
|
+
* therefore must not guess.
|
|
191
|
+
*/
|
|
192
|
+
export async function settlePointer(dir: string, note: PagePointer, probe?: ProbeFetch): Promise<PointerOutcome> {
|
|
193
|
+
let took: PagePointer | undefined;
|
|
194
|
+
for (let attempt = 0; attempt < NOTE_ATTEMPTS; attempt++) {
|
|
195
|
+
// A retry writes a fresh `startedAt`, so a reader can tell it from the first attempt.
|
|
196
|
+
const candidate = attempt === 0 ? note : { ...note, startedAt: note.startedAt + attempt };
|
|
197
|
+
if (await createPointer(dir, candidate))
|
|
198
|
+
return took === undefined ? { kind: "written" } : { kind: "written", took };
|
|
199
|
+
const winner = await readPointer(dir);
|
|
200
|
+
if (winner === undefined) {
|
|
201
|
+
// No note at all means the page that had it cleared it in this instant: try again
|
|
202
|
+
// rather than reporting a file that is not there. Only a file that stays, and
|
|
203
|
+
// cannot be read, is the caller's dead end.
|
|
204
|
+
if (!(await noteExists(dir))) continue;
|
|
205
|
+
return { kind: "unreadable" };
|
|
206
|
+
}
|
|
207
|
+
if (await probePage(winner, probe)) return { kind: "attached", pointer: winner };
|
|
208
|
+
// Only that exact note: a note a newer page wrote is not this caller's to take. A
|
|
209
|
+
// refused clear means somebody else got there first, so the next attempt re-reads.
|
|
210
|
+
if (await clearPointer(dir, winner.viewToken)) took = winner;
|
|
211
|
+
}
|
|
212
|
+
const last = await readPointer(dir);
|
|
213
|
+
if (last !== undefined) return { kind: "stale", pointer: last };
|
|
214
|
+
return (await noteExists(dir)) ? { kind: "unreadable" } : { kind: "busy" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether the page a pointer names is answering.
|
|
219
|
+
*
|
|
220
|
+
* A page that answers with anything but a 200 to the snapshot request is not one to
|
|
221
|
+
* attach to: a port reused by another program, a server shutting down, and a page that
|
|
222
|
+
* refuses the token all read the same, and all mean "serve your own".
|
|
223
|
+
*/
|
|
224
|
+
export async function probePage(
|
|
225
|
+
pointer: PagePointer,
|
|
226
|
+
fetchLike: ProbeFetch = (url, init) => fetch(url, init),
|
|
227
|
+
timeoutMs: number = PROBE_MS,
|
|
228
|
+
): Promise<boolean> {
|
|
229
|
+
try {
|
|
230
|
+
const response = await fetchLike(`http://127.0.0.1:${pointer.port}/api/board?token=${pointer.viewToken}`, {
|
|
231
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
232
|
+
});
|
|
233
|
+
return response.ok;
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A parsed file as a pointer, or undefined when it is not one this build can trust. */
|
|
240
|
+
function asPointer(value: unknown): PagePointer | undefined {
|
|
241
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
242
|
+
const { port, viewToken, seat, startedAt } = value as Record<string, unknown>;
|
|
243
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65_535) return undefined;
|
|
244
|
+
if (typeof viewToken !== "string" || viewToken === "") return undefined;
|
|
245
|
+
if (typeof startedAt !== "number") return undefined;
|
|
246
|
+
if (typeof seat !== "object" || seat === null) return undefined;
|
|
247
|
+
const { id, label } = seat as Record<string, unknown>;
|
|
248
|
+
if (typeof label !== "string" || label === "") return undefined;
|
|
249
|
+
if (id !== undefined && typeof id !== "string") return undefined;
|
|
250
|
+
return {
|
|
251
|
+
port,
|
|
252
|
+
viewToken,
|
|
253
|
+
seat: id === undefined ? { label } : { id, label },
|
|
254
|
+
startedAt,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lead's rollup, as the lines a reader gets.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces want the same sentences: `board`'s tool returns them, because the model's
|
|
5
|
+
* next decision is taken from the counts and the ids, and `team`'s `/team` prints them,
|
|
6
|
+
* because a lead in a terminal should not have to open a browser to see what needs a
|
|
7
|
+
* decision. One text, so the two cannot describe one board differently.
|
|
8
|
+
*
|
|
9
|
+
* It reads the cards and the round and nothing else — no clock, no roster, no store — so
|
|
10
|
+
* `plural` and the group lines are pure and a caller supplies what it already read.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type Card, cardRef } from "./card.ts";
|
|
14
|
+
import type { RoundSummary } from "./round.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The rollup: a headline of what a lead decides on, then one line per group.
|
|
18
|
+
*
|
|
19
|
+
* Groups name their cards by id and state, because the ids are what the next action
|
|
20
|
+
* takes, and an empty group is left out rather than drawn empty.
|
|
21
|
+
*/
|
|
22
|
+
export function formatRound(cards: readonly Card[], round: RoundSummary): string {
|
|
23
|
+
const owners = round.owners.length;
|
|
24
|
+
const headline = [
|
|
25
|
+
plural(owners, "owner"),
|
|
26
|
+
plural(cards.length, "feature"),
|
|
27
|
+
`${round.unassigned.length} unassigned`,
|
|
28
|
+
`${round.unplanned.length} unplanned`,
|
|
29
|
+
`${round.blocked.length} blocked`,
|
|
30
|
+
`${round.idle.length} idle`,
|
|
31
|
+
`${round.done.length} done`,
|
|
32
|
+
].join(" · ");
|
|
33
|
+
const lines = [headline];
|
|
34
|
+
for (const group of round.owners) {
|
|
35
|
+
// An owner who finished everything is still listed, with what it finished: the
|
|
36
|
+
// count says which kind of work is left, and an empty list is a legitimate answer.
|
|
37
|
+
const counts =
|
|
38
|
+
group.done.length > 0 ? `${group.cards.length} live, ${group.done.length} done` : `${group.cards.length}`;
|
|
39
|
+
const listed = group.cards.map((card) => brief(card)).join(", ");
|
|
40
|
+
lines.push(listed === "" ? `${group.label} (${counts})` : `${group.label} (${counts}): ${listed}`);
|
|
41
|
+
}
|
|
42
|
+
lines.push(...group("unassigned", round.unassigned));
|
|
43
|
+
lines.push(...group("unplanned", round.unplanned));
|
|
44
|
+
lines.push(...group("blocked", round.blocked, true));
|
|
45
|
+
lines.push(...group("idle", round.idle));
|
|
46
|
+
lines.push(...group("done", round.done));
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `1 owner`, `2 owners`. */
|
|
51
|
+
export function plural(count: number, noun: string): string {
|
|
52
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `#4 doing planned`, the brief a rollup group needs. */
|
|
56
|
+
function brief(card: Card): string {
|
|
57
|
+
const planned = card.status === "doing" ? (card.plan === undefined ? " unplanned" : " planned") : "";
|
|
58
|
+
return `${cardRef(card.id)} ${card.status}${planned}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One rollup group, or nothing when it is empty. */
|
|
62
|
+
function group(label: string, cards: readonly Card[], withReason = false): string[] {
|
|
63
|
+
if (cards.length === 0) return [];
|
|
64
|
+
const items = cards.map((card) => (withReason ? blockedBrief(card) : brief(card)));
|
|
65
|
+
return [`${label} (${cards.length}): ${items.join(", ")}`];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A blocked card names the note that explains it, if there is one. */
|
|
69
|
+
function blockedBrief(card: Card): string {
|
|
70
|
+
const last = card.notes.at(-1);
|
|
71
|
+
return `${brief(card)}${last === undefined ? "" : ` — ${last.text}`}`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rollup: the lead's view of a board, computed from the cards alone.
|
|
3
|
+
*
|
|
4
|
+
* This is what `board list --round` returns and what the page's Team tab draws, so
|
|
5
|
+
* the two cannot disagree about who holds what. It deliberately reads nothing but
|
|
6
|
+
* the cards: a session's liveness is `session status`'s answer, and the board has
|
|
7
|
+
* to work when `session-bus` is not loaded. `idle` therefore means what a card can
|
|
8
|
+
* know — nobody has touched it in half an hour — not that its owner's process is
|
|
9
|
+
* gone.
|
|
10
|
+
*
|
|
11
|
+
* The groups overlap on purpose. A `doing` card with no plan and no touch for an
|
|
12
|
+
* hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
|
|
13
|
+
* half the reason a lead should look at it.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no I/O and no clock of its own.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Card } from "./card.ts";
|
|
19
|
+
|
|
20
|
+
/** How long a `doing` card may go untouched before the rollup calls it idle. */
|
|
21
|
+
export const IDLE_MS = 30 * 60 * 1_000;
|
|
22
|
+
|
|
23
|
+
/** One owner, the live cards it holds, and the ones it finished. */
|
|
24
|
+
export interface RoundGroup {
|
|
25
|
+
label: string;
|
|
26
|
+
cards: Card[];
|
|
27
|
+
/** This owner's finished cards: history, kept so a teammate who is done is not erased. */
|
|
28
|
+
done: Card[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The lead's rollup of one board. */
|
|
32
|
+
export interface RoundSummary {
|
|
33
|
+
/** Every owner with at least one unfinished card, ordered by label. */
|
|
34
|
+
owners: RoundGroup[];
|
|
35
|
+
/** `todo` cards: nobody has taken them yet. */
|
|
36
|
+
unassigned: Card[];
|
|
37
|
+
/** `doing` cards with no plan recorded: work started before it was planned. */
|
|
38
|
+
unplanned: Card[];
|
|
39
|
+
/** `blocked` cards, and the note that says why. */
|
|
40
|
+
blocked: Card[];
|
|
41
|
+
/** `doing` cards untouched for {@link IDLE_MS}: possibly a dead owner. */
|
|
42
|
+
idle: Card[];
|
|
43
|
+
/** Finished cards, oldest first, kept as history. */
|
|
44
|
+
done: Card[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The rollup of `cards` at `now`.
|
|
49
|
+
*
|
|
50
|
+
* `cards` holds the unfinished cards only: a finished one is also in `done` and
|
|
51
|
+
* counting it twice would make the tallies disagree with the lanes. A finished card
|
|
52
|
+
* still names its owner, in the group's `done`, because an owner whose last card is
|
|
53
|
+
* done has not left the board — dropping the group erased a teammate the round had
|
|
54
|
+
* just finished. Two owners
|
|
55
|
+
* whose labels differ only by case are one owner — a name is case-insensitive when
|
|
56
|
+
* it is a send target, so it is here too — and the first spelling seen is kept.
|
|
57
|
+
*/
|
|
58
|
+
export function buildRound(cards: readonly Card[], now: number): RoundSummary {
|
|
59
|
+
const groups = new Map<string, RoundGroup>();
|
|
60
|
+
const unassigned: Card[] = [];
|
|
61
|
+
const unplanned: Card[] = [];
|
|
62
|
+
const blocked: Card[] = [];
|
|
63
|
+
const idle: Card[] = [];
|
|
64
|
+
const done: Card[] = [];
|
|
65
|
+
|
|
66
|
+
for (const card of cards) {
|
|
67
|
+
if (card.status === "todo") unassigned.push(card);
|
|
68
|
+
if (card.status === "done") done.push(card);
|
|
69
|
+
if (card.status === "blocked") blocked.push(card);
|
|
70
|
+
if (card.status === "doing" && card.plan === undefined) unplanned.push(card);
|
|
71
|
+
if (card.status === "doing" && now - card.updatedAt >= IDLE_MS) idle.push(card);
|
|
72
|
+
|
|
73
|
+
if (card.owner === undefined) continue;
|
|
74
|
+
const key = card.owner.label.toLowerCase();
|
|
75
|
+
const group = groups.get(key) ?? { label: card.owner.label, cards: [], done: [] };
|
|
76
|
+
if (card.status === "done") group.done.push(card);
|
|
77
|
+
else group.cards.push(card);
|
|
78
|
+
groups.set(key, group);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
owners: [...groups.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
|
83
|
+
unassigned,
|
|
84
|
+
unplanned,
|
|
85
|
+
blocked,
|
|
86
|
+
idle,
|
|
87
|
+
done,
|
|
88
|
+
};
|
|
89
|
+
}
|