@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,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file-backed store for one repository's session bus.
|
|
3
|
+
*
|
|
4
|
+
* Every session writes its own record, every message is its own file, and every
|
|
5
|
+
* claim is an exclusive-create file. That keeps concurrent Pi processes off one
|
|
6
|
+
* shared lock while still making the common operations atomic. The store knows
|
|
7
|
+
* nothing about models or UI; it only reads and writes validated JSON.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import { mkdir, link, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { asRecord, readJson } from "../../lib/config.ts";
|
|
14
|
+
import { claimsConflict } from "./overlap.ts";
|
|
15
|
+
import type { BusMessage, ClaimConflict, ClaimRecord, SessionRecord } from "./types.ts";
|
|
16
|
+
import { INBOX_DIR_NAME, SESSIONS_DIR_NAME } from "../../lib/session-roster.ts";
|
|
17
|
+
|
|
18
|
+
const DIR_MODE = 0o700;
|
|
19
|
+
const FILE_MODE = 0o600;
|
|
20
|
+
|
|
21
|
+
export interface BusStoreOptions {
|
|
22
|
+
/** The repository bus directory, e.g. `<agent-dir>/session-bus/<key>`. */
|
|
23
|
+
dir: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BusStore {
|
|
27
|
+
readonly dir: string;
|
|
28
|
+
ensure(): Promise<void>;
|
|
29
|
+
writeSession(record: SessionRecord): Promise<void>;
|
|
30
|
+
sessions(): Promise<SessionRecord[]>;
|
|
31
|
+
/** One session's record, or undefined when it is not on disk. */
|
|
32
|
+
session(id: string): Promise<SessionRecord | undefined>;
|
|
33
|
+
removeSession(id: string): Promise<void>;
|
|
34
|
+
writeMessage(target: string, message: BusMessage): Promise<void>;
|
|
35
|
+
/** Read a recipient's messages without consuming them, oldest first. */
|
|
36
|
+
peek(id: string, limit: number): Promise<BusMessage[]>;
|
|
37
|
+
inbox(id: string, limit: number): Promise<BusMessage[]>;
|
|
38
|
+
unread(id: string): Promise<number>;
|
|
39
|
+
claims(): Promise<ClaimRecord[]>;
|
|
40
|
+
claimAll(
|
|
41
|
+
paths: readonly string[],
|
|
42
|
+
owner: string,
|
|
43
|
+
ownerLabel: string,
|
|
44
|
+
at: number,
|
|
45
|
+
): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
|
|
46
|
+
release(path: string, owner: string): Promise<boolean>;
|
|
47
|
+
releaseAll(owner: string): Promise<string[]>;
|
|
48
|
+
prune(input: {
|
|
49
|
+
now: number;
|
|
50
|
+
activeIds: ReadonlySet<string>;
|
|
51
|
+
sessionTtlMs: number;
|
|
52
|
+
messageTtlMs: number;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Release each path through `release`, reporting the ones this session did not
|
|
58
|
+
* hold. One copy, because `release` and `leave` answer the same two lists, and a
|
|
59
|
+
* second loop is how the two answers drift.
|
|
60
|
+
*/
|
|
61
|
+
export async function releaseClaimed(
|
|
62
|
+
paths: readonly string[],
|
|
63
|
+
release: (path: string) => Promise<boolean>,
|
|
64
|
+
): Promise<{ released: string[]; notOwned: string[] }> {
|
|
65
|
+
const released: string[] = [];
|
|
66
|
+
const notOwned: string[] = [];
|
|
67
|
+
for (const path of paths) {
|
|
68
|
+
if (await release(path)) released.push(path);
|
|
69
|
+
else notOwned.push(path);
|
|
70
|
+
}
|
|
71
|
+
return { released, notOwned };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SHA = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
75
|
+
|
|
76
|
+
function isSession(value: unknown): value is SessionRecord {
|
|
77
|
+
const entry = asRecord(value);
|
|
78
|
+
if (!entry) return false;
|
|
79
|
+
return (
|
|
80
|
+
typeof entry.id === "string" &&
|
|
81
|
+
typeof entry.label === "string" &&
|
|
82
|
+
typeof entry.pid === "number" &&
|
|
83
|
+
typeof entry.cwd === "string" &&
|
|
84
|
+
typeof entry.repoRoot === "string" &&
|
|
85
|
+
typeof entry.mode === "string" &&
|
|
86
|
+
typeof entry.startedAt === "number" &&
|
|
87
|
+
typeof entry.lastSeen === "number" &&
|
|
88
|
+
(entry.watching === undefined || isWatchingState(entry.watching))
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The optional listening field, validated only when it is there. */
|
|
93
|
+
function isWatchingState(value: unknown): boolean {
|
|
94
|
+
const entry = asRecord(value);
|
|
95
|
+
return entry !== undefined && typeof entry.armed === "boolean" && typeof entry.wake === "boolean";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isMessage(value: unknown): value is BusMessage {
|
|
99
|
+
const entry = asRecord(value);
|
|
100
|
+
if (!entry) return false;
|
|
101
|
+
return (
|
|
102
|
+
typeof entry.id === "string" &&
|
|
103
|
+
typeof entry.from === "string" &&
|
|
104
|
+
typeof entry.fromLabel === "string" &&
|
|
105
|
+
typeof entry.to === "string" &&
|
|
106
|
+
typeof entry.toLabel === "string" &&
|
|
107
|
+
typeof entry.kind === "string" &&
|
|
108
|
+
typeof entry.text === "string" &&
|
|
109
|
+
typeof entry.at === "number" &&
|
|
110
|
+
(entry.paths === undefined || (Array.isArray(entry.paths) && entry.paths.every((path) => typeof path === "string")))
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isClaim(value: unknown): value is ClaimRecord {
|
|
115
|
+
const entry = asRecord(value);
|
|
116
|
+
if (!entry) return false;
|
|
117
|
+
return (
|
|
118
|
+
typeof entry.path === "string" &&
|
|
119
|
+
typeof entry.owner === "string" &&
|
|
120
|
+
typeof entry.ownerLabel === "string" &&
|
|
121
|
+
typeof entry.at === "number"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readTyped<T>(path: string, guard: (value: unknown) => value is T): T | undefined {
|
|
126
|
+
const parsed = readJson(path);
|
|
127
|
+
return parsed !== undefined && guard(parsed) ? parsed : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function writeJson(path: string, value: unknown): Promise<void> {
|
|
131
|
+
await mkdir(dirname(path), { recursive: true, mode: DIR_MODE });
|
|
132
|
+
const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
133
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf-8", mode: FILE_MODE });
|
|
134
|
+
try {
|
|
135
|
+
await rename(temp, path);
|
|
136
|
+
} catch {
|
|
137
|
+
// Windows cannot replace an existing file with rename; removing first keeps
|
|
138
|
+
// the write recoverable, and the window is only a heartbeat-sized race.
|
|
139
|
+
await rm(path, { force: true });
|
|
140
|
+
await rename(temp, path);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function listFiles(dir: string): Promise<string[]> {
|
|
145
|
+
try {
|
|
146
|
+
return (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Drop a recipient's directory once it holds no files, so a bus does not keep
|
|
154
|
+
* one empty directory for every session that ever joined it. An inbox with mail
|
|
155
|
+
* stays, because a session's id survives a resume and its mail is still its own;
|
|
156
|
+
* non-recursive removal also loses the race to a message that lands meanwhile.
|
|
157
|
+
*/
|
|
158
|
+
async function dropIfEmpty(dir: string): Promise<void> {
|
|
159
|
+
const entries = await readdir(dir).catch(() => null);
|
|
160
|
+
if (!entries || entries.length > 0) return;
|
|
161
|
+
await rmdir(dir).catch(() => {});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Create a store rooted at one repository's bus directory. */
|
|
165
|
+
export function createBusStore(options: BusStoreOptions): BusStore {
|
|
166
|
+
const { dir } = options;
|
|
167
|
+
const sessionsDir = join(dir, SESSIONS_DIR_NAME);
|
|
168
|
+
const inboxRoot = join(dir, INBOX_DIR_NAME);
|
|
169
|
+
const claimsDir = join(dir, "claims");
|
|
170
|
+
|
|
171
|
+
const sessionFile = (id: string): string => join(sessionsDir, `${SHA(id)}.json`);
|
|
172
|
+
const inboxDir = (id: string): string => join(inboxRoot, SHA(id));
|
|
173
|
+
const claimFile = (path: string): string => join(claimsDir, `${SHA(path)}.json`);
|
|
174
|
+
|
|
175
|
+
async function readSessions(): Promise<SessionRecord[]> {
|
|
176
|
+
const sessions: SessionRecord[] = [];
|
|
177
|
+
for (const name of await listFiles(sessionsDir)) {
|
|
178
|
+
const record = readTyped(join(sessionsDir, name), isSession);
|
|
179
|
+
if (record) sessions.push(record);
|
|
180
|
+
}
|
|
181
|
+
return sessions.sort((a, b) => b.lastSeen - a.lastSeen || a.label.localeCompare(b.label));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readMessages(id: string, limit: number, remove: boolean): Promise<BusMessage[]> {
|
|
185
|
+
const directory = inboxDir(id);
|
|
186
|
+
const messages: BusMessage[] = [];
|
|
187
|
+
for (const name of await listFiles(directory)) {
|
|
188
|
+
const path = join(directory, name);
|
|
189
|
+
const message = readTyped(path, isMessage);
|
|
190
|
+
if (message) messages.push(message);
|
|
191
|
+
if (remove || !message) await rm(path, { force: true });
|
|
192
|
+
if (messages.length >= limit) break;
|
|
193
|
+
}
|
|
194
|
+
return messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id)).slice(0, limit);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function readClaims(): Promise<ClaimRecord[]> {
|
|
198
|
+
const claims: ClaimRecord[] = [];
|
|
199
|
+
for (const name of await listFiles(claimsDir)) {
|
|
200
|
+
const claim = readTyped(join(claimsDir, name), isClaim);
|
|
201
|
+
if (claim) claims.push(claim);
|
|
202
|
+
}
|
|
203
|
+
return claims.sort((a, b) => a.path.localeCompare(b.path));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function claimOne(
|
|
207
|
+
path: string,
|
|
208
|
+
owner: string,
|
|
209
|
+
ownerLabel: string,
|
|
210
|
+
at: number,
|
|
211
|
+
): Promise<{ claim: ClaimRecord; conflict?: ClaimRecord }> {
|
|
212
|
+
await mkdir(claimsDir, { recursive: true, mode: DIR_MODE });
|
|
213
|
+
const file = claimFile(path);
|
|
214
|
+
const claim: ClaimRecord = { path, owner, ownerLabel, at };
|
|
215
|
+
// Publish a written temp file instead of creating the claim and filling it
|
|
216
|
+
// afterwards: the link is the exclusive create, so no reader can observe a
|
|
217
|
+
// half-written claim and mistake it for a crashed owner's.
|
|
218
|
+
const temp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
219
|
+
await writeFile(temp, `${JSON.stringify(claim, null, 2)}\n`, { encoding: "utf-8", mode: FILE_MODE });
|
|
220
|
+
try {
|
|
221
|
+
try {
|
|
222
|
+
await link(temp, file);
|
|
223
|
+
return { claim };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
226
|
+
}
|
|
227
|
+
const existing = readTyped(file, isClaim);
|
|
228
|
+
if (existing) {
|
|
229
|
+
return existing.owner === owner ? { claim: existing } : { claim, conflict: existing };
|
|
230
|
+
}
|
|
231
|
+
// A claim file that does not parse is debris from before claims were
|
|
232
|
+
// published whole; clear it once so a stale path cannot block forever.
|
|
233
|
+
await rm(file, { force: true });
|
|
234
|
+
await link(temp, file);
|
|
235
|
+
return { claim };
|
|
236
|
+
} finally {
|
|
237
|
+
await rm(temp, { force: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function releaseOne(path: string, owner: string): Promise<boolean> {
|
|
242
|
+
const file = claimFile(path);
|
|
243
|
+
const existing = readTyped(file, isClaim);
|
|
244
|
+
if (!existing || existing.owner !== owner) return false;
|
|
245
|
+
await rm(file, { force: true });
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Claim every path for one owner, or nothing.
|
|
251
|
+
*
|
|
252
|
+
* The pre-check reads the claims once and reports every blocking owner, so a
|
|
253
|
+
* batch that cannot be satisfied touches no file. A create that loses a race
|
|
254
|
+
* after the pre-check releases the paths this batch already took.
|
|
255
|
+
*/
|
|
256
|
+
async function claimAll(
|
|
257
|
+
paths: readonly string[],
|
|
258
|
+
owner: string,
|
|
259
|
+
ownerLabel: string,
|
|
260
|
+
at: number,
|
|
261
|
+
): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }> {
|
|
262
|
+
const existing = await readClaims();
|
|
263
|
+
const conflicts: ClaimConflict[] = [];
|
|
264
|
+
for (const path of paths) {
|
|
265
|
+
for (const held of existing) {
|
|
266
|
+
if (held.owner !== owner && claimsConflict(path, held.path)) {
|
|
267
|
+
conflicts.push({ path, owner: held.owner, ownerLabel: held.ownerLabel });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (conflicts.length > 0) return { claimed: [], conflicts };
|
|
272
|
+
const claimed: string[] = [];
|
|
273
|
+
for (const path of paths) {
|
|
274
|
+
const result = await claimOne(path, owner, ownerLabel, at);
|
|
275
|
+
if (result.conflict) {
|
|
276
|
+
for (const done of claimed) await releaseOne(done, owner);
|
|
277
|
+
const { owner: holder, ownerLabel: holderLabel } = result.conflict;
|
|
278
|
+
return { claimed: [], conflicts: [{ path, owner: holder, ownerLabel: holderLabel }] };
|
|
279
|
+
}
|
|
280
|
+
claimed.push(path);
|
|
281
|
+
}
|
|
282
|
+
return { claimed, conflicts: [] };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
dir,
|
|
287
|
+
async ensure() {
|
|
288
|
+
await Promise.all([
|
|
289
|
+
mkdir(sessionsDir, { recursive: true, mode: DIR_MODE }),
|
|
290
|
+
mkdir(inboxRoot, { recursive: true, mode: DIR_MODE }),
|
|
291
|
+
mkdir(claimsDir, { recursive: true, mode: DIR_MODE }),
|
|
292
|
+
]);
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
async writeSession(record) {
|
|
296
|
+
await writeJson(sessionFile(record.id), record);
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
sessions: readSessions,
|
|
300
|
+
|
|
301
|
+
session: async (id) => readTyped(sessionFile(id), isSession),
|
|
302
|
+
|
|
303
|
+
async removeSession(id) {
|
|
304
|
+
await rm(sessionFile(id), { force: true });
|
|
305
|
+
await dropIfEmpty(inboxDir(id));
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
async writeMessage(target, message) {
|
|
309
|
+
const directory = inboxDir(target);
|
|
310
|
+
await mkdir(directory, { recursive: true, mode: DIR_MODE });
|
|
311
|
+
await writeJson(join(directory, `${message.id}.json`), message);
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
peek: (id, limit) => readMessages(id, limit, false),
|
|
315
|
+
|
|
316
|
+
inbox: (id, limit) => readMessages(id, limit, true),
|
|
317
|
+
|
|
318
|
+
async unread(id) {
|
|
319
|
+
return (await listFiles(inboxDir(id))).length;
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
claims: readClaims,
|
|
323
|
+
|
|
324
|
+
claimAll,
|
|
325
|
+
|
|
326
|
+
release: releaseOne,
|
|
327
|
+
|
|
328
|
+
async releaseAll(owner) {
|
|
329
|
+
const released: string[] = [];
|
|
330
|
+
for (const claim of await readClaims()) {
|
|
331
|
+
if (claim.owner !== owner) continue;
|
|
332
|
+
await rm(claimFile(claim.path), { force: true });
|
|
333
|
+
released.push(claim.path);
|
|
334
|
+
}
|
|
335
|
+
return released;
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
async prune({ now: at, activeIds, sessionTtlMs, messageTtlMs }) {
|
|
339
|
+
// The caller's set can lag: a peer that joined and claimed since that read is
|
|
340
|
+
// live on disk, and its claims are its own. This read is the answer, so the
|
|
341
|
+
// set only adds to what it found rather than deciding who survives.
|
|
342
|
+
const live = new Set(activeIds);
|
|
343
|
+
for (const session of await readSessions()) {
|
|
344
|
+
if (at - session.lastSeen > sessionTtlMs) {
|
|
345
|
+
await rm(sessionFile(session.id), { force: true });
|
|
346
|
+
await dropIfEmpty(inboxDir(session.id));
|
|
347
|
+
live.delete(session.id);
|
|
348
|
+
} else {
|
|
349
|
+
live.add(session.id);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const claim of await readClaims()) {
|
|
353
|
+
if (!live.has(claim.owner)) await rm(claimFile(claim.path), { force: true });
|
|
354
|
+
}
|
|
355
|
+
for (const name of await readdir(inboxRoot).catch(() => [] as string[])) {
|
|
356
|
+
const directory = join(inboxRoot, name);
|
|
357
|
+
for (const file of await listFiles(directory)) {
|
|
358
|
+
const message = readTyped(join(directory, file), isMessage);
|
|
359
|
+
if (!message || at - message.at > messageTtlMs) await rm(join(directory, file), { force: true });
|
|
360
|
+
}
|
|
361
|
+
await dropIfEmpty(directory);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `session` tool.
|
|
3
|
+
*
|
|
4
|
+
* One action-shaped tool keeps the prompt surface small: status, send, inbox,
|
|
5
|
+
* claim, release, join. The runtime owns file I/O and lifecycle; this
|
|
6
|
+
* module only validates the action, calls the runtime, and hands the result to
|
|
7
|
+
* the shared presentation module — one collapsed line, or the board's rows on
|
|
8
|
+
* expand.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Type, type Static } from "typebox";
|
|
14
|
+
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
15
|
+
import { errorResult, textResult } from "../../lib/tool-result.ts";
|
|
16
|
+
import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
|
|
17
|
+
import { formatClaim, formatInbox, formatJoin, formatRelease, formatSend, formatStatus } from "./format.ts";
|
|
18
|
+
import { normalizeAction, TOOL_NAME } from "./protocol.ts";
|
|
19
|
+
import type { SessionBus } from "./runtime.ts";
|
|
20
|
+
import { collapsedLine, resultSections, SessionResult } from "./result.ts";
|
|
21
|
+
import type { SessionDetails } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
export { TOOL_NAME };
|
|
24
|
+
|
|
25
|
+
const SessionParams = Type.Object({
|
|
26
|
+
action: StringEnum(["status", "send", "inbox", "claim", "release", "join"] as const, {
|
|
27
|
+
description: "status, send, inbox, claim, release, or join.",
|
|
28
|
+
}),
|
|
29
|
+
to: Type.Optional(Type.String({ description: "Target session label/id, or * for an event broadcast." })),
|
|
30
|
+
text: Type.Optional(Type.String({ description: "Message text, at most 4000 characters." })),
|
|
31
|
+
kind: Type.Optional(
|
|
32
|
+
StringEnum(["message", "event", "ask", "handoff"] as const, {
|
|
33
|
+
description: "Message kind; defaults to message.",
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
paths: Type.Optional(
|
|
37
|
+
Type.Array(Type.String(), { description: "Repository-relative file or directory paths to claim or release." }),
|
|
38
|
+
),
|
|
39
|
+
limit: Type.Optional(
|
|
40
|
+
Type.Integer({ minimum: 1, maximum: 50, description: "Inbox messages to consume (default 20)." }),
|
|
41
|
+
),
|
|
42
|
+
wake: Type.Optional(
|
|
43
|
+
Type.Boolean({
|
|
44
|
+
description: "With join: mail raises one turn per burst once the agent is idle; omitted waits for the next turn.",
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
listen: Type.Optional(
|
|
48
|
+
Type.Boolean({
|
|
49
|
+
description: "With join: false joins without listening, so mail waits in the inbox until `inbox` reads it.",
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
label: Type.Optional(
|
|
53
|
+
Type.String({ description: "With join: what other sessions call this one, e.g. `billing-lead`." }),
|
|
54
|
+
),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
type SessionArgs = Static<typeof SessionParams>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The call line's subject: the verb, and what it acts on. `end` clipping keeps
|
|
61
|
+
* the verb readable, because the paths that follow are the detail, not the
|
|
62
|
+
* identity of the call.
|
|
63
|
+
*/
|
|
64
|
+
function callSubject(action: string, args: Record<string, unknown>): string {
|
|
65
|
+
if (action === "join") {
|
|
66
|
+
const label = typeof args.label === "string" ? args.label.trim() : "";
|
|
67
|
+
return label ? `join ${label}` : "join";
|
|
68
|
+
}
|
|
69
|
+
if (action === "send") {
|
|
70
|
+
const to = typeof args.to === "string" ? args.to : "";
|
|
71
|
+
return to ? `send ${to}` : "send";
|
|
72
|
+
}
|
|
73
|
+
if (action !== "claim" && action !== "release") return action;
|
|
74
|
+
const paths = Array.isArray(args.paths) ? args.paths.filter((path): path is string => typeof path === "string") : [];
|
|
75
|
+
const [first, ...rest] = paths;
|
|
76
|
+
if (first === undefined) return action;
|
|
77
|
+
return `${action} ${first}${rest.length > 0 ? ` +${rest.length}` : ""}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function registerTools(pi: ExtensionAPI, bus: SessionBus): void {
|
|
81
|
+
pi.registerTool({
|
|
82
|
+
name: TOOL_NAME,
|
|
83
|
+
label: "Session bus",
|
|
84
|
+
description:
|
|
85
|
+
"Coordinate parallel Pi sessions on this repository. Actions: `status` (sessions, unread, claims), " +
|
|
86
|
+
"`send` (to a label/id, or `*` for an event), `inbox` (consume messages), `claim`/`release` (paths), " +
|
|
87
|
+
"`join` (get on the bus under a label, and listen for mail unless `listen: false`; `wake` raises a turn). " +
|
|
88
|
+
"Delivery is pull-based unless this session joined and is listening.",
|
|
89
|
+
promptSnippet: "Coordinate parallel sessions: status, send, inbox, claim, release, join.",
|
|
90
|
+
promptGuidelines: [
|
|
91
|
+
"Check `session inbox` when starting a coordinated turn.",
|
|
92
|
+
"Claim the paths you will edit before changing them; release them when done.",
|
|
93
|
+
'Send `kind: "event"` for contract, build, or test changes another session needs.',
|
|
94
|
+
"Never put credentials or secrets in a session-bus message.",
|
|
95
|
+
],
|
|
96
|
+
parameters: SessionParams,
|
|
97
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
98
|
+
executionMode: "sequential",
|
|
99
|
+
|
|
100
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
101
|
+
const args = params as SessionArgs;
|
|
102
|
+
const action = normalizeAction(args.action);
|
|
103
|
+
if (!action) return errorResult("status", `unknown action "${String(args.action)}".`);
|
|
104
|
+
try {
|
|
105
|
+
await bus.start(ctx);
|
|
106
|
+
switch (action) {
|
|
107
|
+
case "status": {
|
|
108
|
+
const status = await bus.status();
|
|
109
|
+
return textResult(formatStatus(status), { action, status });
|
|
110
|
+
}
|
|
111
|
+
case "send": {
|
|
112
|
+
const result = await bus.send({
|
|
113
|
+
to: typeof args.to === "string" ? args.to : "",
|
|
114
|
+
text: typeof args.text === "string" ? args.text : "",
|
|
115
|
+
kind: args.kind,
|
|
116
|
+
paths: args.paths,
|
|
117
|
+
});
|
|
118
|
+
return textResult(formatSend(result.delivered, result.messageId), {
|
|
119
|
+
action,
|
|
120
|
+
delivered: result.delivered,
|
|
121
|
+
messageId: result.messageId,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
case "inbox": {
|
|
125
|
+
const messages = await bus.inbox(args.limit);
|
|
126
|
+
return textResult(formatInbox(messages), { action, messages });
|
|
127
|
+
}
|
|
128
|
+
case "claim": {
|
|
129
|
+
const result = await bus.claim(args.paths);
|
|
130
|
+
return textResult(formatClaim(result.claimed, result.conflicts), {
|
|
131
|
+
action,
|
|
132
|
+
claimed: result.claimed,
|
|
133
|
+
conflicts: result.conflicts,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
case "release": {
|
|
137
|
+
const result = await bus.release(args.paths);
|
|
138
|
+
return textResult(formatRelease(result.released, result.notOwned), {
|
|
139
|
+
action,
|
|
140
|
+
released: result.released,
|
|
141
|
+
notOwned: result.notOwned,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
case "join": {
|
|
145
|
+
const label = typeof args.label === "string" ? args.label.trim() : "";
|
|
146
|
+
const joined = await bus.join({
|
|
147
|
+
...(label === "" ? {} : { label }),
|
|
148
|
+
...(args.wake === undefined ? {} : { wake: args.wake }),
|
|
149
|
+
...(args.listen === undefined ? {} : { listen: args.listen }),
|
|
150
|
+
});
|
|
151
|
+
// A label is the bus's name and Pi's session name in one act, so the footer,
|
|
152
|
+
// `/resume`, and the record a peer addresses agree. A bare join derives its
|
|
153
|
+
// label from the name Pi already shows and must not rename it back.
|
|
154
|
+
if (label !== "") pi.setSessionName(joined.label);
|
|
155
|
+
return textResult(formatJoin(joined.label, joined.watching), {
|
|
156
|
+
action,
|
|
157
|
+
label: joined.label,
|
|
158
|
+
watching: joined.watching,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return errorResult(action, error instanceof Error ? error.message : String(error));
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
renderCall(args, theme, context) {
|
|
168
|
+
const action = typeof args.action === "string" ? args.action : "session";
|
|
169
|
+
return callLine(theme, context.lastComponent, TOOL_NAME, callSubject(action, args as Record<string, unknown>));
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
renderResult(result, options, theme, context) {
|
|
173
|
+
const text = reuseText(context?.lastComponent);
|
|
174
|
+
const details = toolDetails<SessionDetails>(result.details);
|
|
175
|
+
const outcome = detailsOrLine(theme, result, options.expanded, details);
|
|
176
|
+
if ("line" in outcome) {
|
|
177
|
+
text.setText(outcome.line);
|
|
178
|
+
return text;
|
|
179
|
+
}
|
|
180
|
+
const value = outcome.details;
|
|
181
|
+
const sections = options.expanded ? resultSections(value) : undefined;
|
|
182
|
+
if (!sections) {
|
|
183
|
+
text.setText(collapsedLine(theme, value));
|
|
184
|
+
return text;
|
|
185
|
+
}
|
|
186
|
+
const view =
|
|
187
|
+
context?.lastComponent instanceof SessionResult ? context.lastComponent : new SessionResult(sections, theme);
|
|
188
|
+
view.setInput(sections, theme);
|
|
189
|
+
return view;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|