@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 +29 -15
- package/README.zh-CN.md +27 -15
- package/extensions/bytefly/README.md +195 -115
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +29 -39
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +89 -22
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +74 -459
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +26 -152
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +27 -6
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +10 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +35 -52
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/README.md +3 -3
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +333 -84
- package/extensions/plan/commands.ts +73 -7
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +184 -58
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +95 -95
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +123 -107
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +17 -12
- package/extensions/plan/tools/exit.ts +148 -59
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +29 -31
- package/extensions/plan/tools.ts +6 -3
- package/extensions/plan/tui.ts +60 -15
- package/extensions/plan/types.ts +45 -3
- package/extensions/plan/web-chrome.ts +102 -0
- package/extensions/plan/web-client.ts +177 -446
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +131 -19
- package/extensions/plan/web-router.ts +148 -32
- package/extensions/plan/web-share-session.ts +197 -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 +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +26 -13
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +25 -44
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +45 -36
- package/extensions/wechat/bridge.ts +46 -48
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +3 -3
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +54 -18
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +9 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +53 -23
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -40
- package/lib/README.md +12 -7
- package/lib/claims/overlap.ts +43 -0
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +28 -1
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +117 -25
- package/lib/requirements.ts +1 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +30 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +91 -19
- package/lib/tui/gauge.ts +9 -5
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +5 -2
- package/skills/changelog/SKILL.md +57 -45
- 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 +209 -0
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-bus lifecycle and operations.
|
|
3
|
+
*
|
|
4
|
+
* A terminal session registers on `session_start`. RPC, JSON, print, and WeChat
|
|
5
|
+
* peer sessions stay invisible until they call the tool or `/sessions`, so a
|
|
6
|
+
* subagent or phone peer does not join a team merely because the package loaded.
|
|
7
|
+
* The runtime owns the heartbeat, keeps no module-level state, and uses the
|
|
8
|
+
* common git directory so worktrees of one repository share one bus.
|
|
9
|
+
*
|
|
10
|
+
* An armed inbox watch polls this session's own inbox directory and hands what
|
|
11
|
+
* arrived to the caller's `onWake`, which the extension wires to the shared
|
|
12
|
+
* pending reporter. A wake that arrives while the agent is running is queued and
|
|
13
|
+
* raised by that reporter once the run settles. It announces and never consumes:
|
|
14
|
+
* `inbox` keeps sole ownership of deleting mail, so a wake cannot eat a message
|
|
15
|
+
* the model did not read.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { join, relative, sep } from "node:path";
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { isInside, realPathOfNearest } from "../../lib/path.ts";
|
|
23
|
+
import { SPLIT_OFF_NOTE, splitEnabled } from "../../lib/plans/setting.ts";
|
|
24
|
+
import { plansDir } from "../../lib/plans/store.ts";
|
|
25
|
+
import { repoKeyFor } from "../../lib/repo-key.ts";
|
|
26
|
+
import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
|
|
27
|
+
import {
|
|
28
|
+
defaultLabel,
|
|
29
|
+
isAutoJoinMode,
|
|
30
|
+
MAX_LABEL,
|
|
31
|
+
MAX_LIMIT,
|
|
32
|
+
normalizeKind,
|
|
33
|
+
normalizeLabel,
|
|
34
|
+
normalizeLimit,
|
|
35
|
+
normalizePaths,
|
|
36
|
+
normalizeText,
|
|
37
|
+
resolveRecipients,
|
|
38
|
+
sessionMode,
|
|
39
|
+
claimsConflict,
|
|
40
|
+
shortId,
|
|
41
|
+
} from "./protocol.ts";
|
|
42
|
+
import { createBusStore, type BusStore } from "./store.ts";
|
|
43
|
+
import { newestTeam, resolveUnit, unitHolder } from "./team.ts";
|
|
44
|
+
import type { BusMessage, ClaimConflict, SessionRecord, SessionStatus } from "./types.ts";
|
|
45
|
+
|
|
46
|
+
export interface SessionBusOptions {
|
|
47
|
+
now?: () => number;
|
|
48
|
+
/** Test seam: use this bus directory instead of the agent-dir layout. */
|
|
49
|
+
dir?: string;
|
|
50
|
+
/** Test seam: skip git and use this repository identity. */
|
|
51
|
+
repo?: { root: string; key: string };
|
|
52
|
+
/** Test seam: read teams from this plan store instead of the agent-dir layout. */
|
|
53
|
+
plans?: string;
|
|
54
|
+
/** Heartbeat interval; 0 disables it (tests). */
|
|
55
|
+
heartbeatMs?: number;
|
|
56
|
+
/** Inbox-watch poll interval; 0 disables it (tests). */
|
|
57
|
+
watchMs?: number;
|
|
58
|
+
/** Test seam: the repeating timer behind the heartbeat and the poll. */
|
|
59
|
+
interval?: RepeatTimer;
|
|
60
|
+
sessionTtlMs?: number;
|
|
61
|
+
messageTtlMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SessionBus {
|
|
65
|
+
start(ctx: ExtensionContext): Promise<void>;
|
|
66
|
+
stop(): Promise<void>;
|
|
67
|
+
status(): Promise<SessionStatus>;
|
|
68
|
+
/** The live bus, read only: no record refresh, no prune. */
|
|
69
|
+
snapshot(): Promise<SessionStatus>;
|
|
70
|
+
/** This session's unread mail, read only: nothing is consumed or marked. */
|
|
71
|
+
peek(): Promise<BusMessage[]>;
|
|
72
|
+
/** Release a claim by its stored repository-relative path. */
|
|
73
|
+
releaseStored(path: string): Promise<boolean>;
|
|
74
|
+
send(input: {
|
|
75
|
+
to: string;
|
|
76
|
+
text: string;
|
|
77
|
+
kind?: string;
|
|
78
|
+
paths?: unknown;
|
|
79
|
+
}): Promise<{ delivered: string[]; messageId: string }>;
|
|
80
|
+
inbox(limit?: number): Promise<BusMessage[]>;
|
|
81
|
+
claim(paths: unknown): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
|
|
82
|
+
release(paths: unknown): Promise<{ released: string[]; notOwned: string[] }>;
|
|
83
|
+
/** Claim every path of one unit in a split plan. */
|
|
84
|
+
join(input: {
|
|
85
|
+
team?: string;
|
|
86
|
+
unit?: string;
|
|
87
|
+
}): Promise<{ team: string; unit: string; claimed: string[]; conflicts: ClaimConflict[] }>;
|
|
88
|
+
/** Release the paths of one unit, or every claim this session holds. */
|
|
89
|
+
leave(input: { team?: string; unit?: string }): Promise<{ released: string[]; notOwned: string[] }>;
|
|
90
|
+
rename(label: string): Promise<string>;
|
|
91
|
+
/** Arm or disarm this session's inbox watch. */
|
|
92
|
+
watch(input: { wake?: boolean; stop?: boolean }): Promise<{ armed: boolean; wake: boolean }>;
|
|
93
|
+
/** Whether the armed watch asked for a triggered turn; the settle retry reads it. */
|
|
94
|
+
wantsWake(): boolean;
|
|
95
|
+
/** Read the inbox without consuming it, queueing what is new; no-op when unarmed. */
|
|
96
|
+
poll(): Promise<void> /** Mail that arrived and has not been reported yet, oldest first. */;
|
|
97
|
+
pending(): BusMessage[];
|
|
98
|
+
/** Drop mail from the pending queue, once reported or consumed. */
|
|
99
|
+
markReported(ids: readonly string[]): void;
|
|
100
|
+
/** Set what to call when new mail arrives and this session asked to be woken. */
|
|
101
|
+
set onWake(callback: (() => void) | undefined);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
105
|
+
const DEFAULT_WATCH_MS = 2_000;
|
|
106
|
+
const DEFAULT_SESSION_TTL_MS = 10 * 60_000;
|
|
107
|
+
const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60_000;
|
|
108
|
+
function messageId(now: number): string {
|
|
109
|
+
return `${now}-${randomBytes(4).toString("hex")}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a record's heartbeat is fresh: the rule the bus prunes and displays by. */
|
|
113
|
+
function isLive(session: SessionRecord, at: number, ttlMs: number): boolean {
|
|
114
|
+
return at - session.lastSeen <= ttlMs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A label with this session's short id appended, inside the label budget. */
|
|
118
|
+
function suffixLabel(label: string, id: string): string {
|
|
119
|
+
const suffix = `-${shortId(id)}`;
|
|
120
|
+
return `${label.slice(0, MAX_LABEL - suffix.length)}${suffix}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Re-key accepted paths by their real target, so a symlinked spelling inside the
|
|
125
|
+
* repository and the file it names are one claim. The paths arrive relative and
|
|
126
|
+
* already inside the root; a link that leaves it is refused here.
|
|
127
|
+
*/
|
|
128
|
+
function canonicalPaths(root: string, paths: readonly string[]): { paths: string[] } | { error: string } {
|
|
129
|
+
const realRoot = realPathOfNearest(root);
|
|
130
|
+
const canonical: string[] = [];
|
|
131
|
+
for (const path of paths) {
|
|
132
|
+
const target = realPathOfNearest(join(root, path));
|
|
133
|
+
const rel = relative(realRoot, target);
|
|
134
|
+
if (rel === "" || !isInside(realRoot, target)) {
|
|
135
|
+
return { error: `path "${path}" does not resolve to a file in the repository.` };
|
|
136
|
+
}
|
|
137
|
+
const normalized = rel.split(sep).join("/");
|
|
138
|
+
if (!canonical.includes(normalized)) canonical.push(normalized);
|
|
139
|
+
}
|
|
140
|
+
return { paths: canonical };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sessionName(ctx: ExtensionContext): string | undefined {
|
|
144
|
+
try {
|
|
145
|
+
return ctx.sessionManager.getSessionName();
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions = {}): SessionBus {
|
|
152
|
+
const now = options.now ?? Date.now;
|
|
153
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
154
|
+
const watchMs = options.watchMs ?? DEFAULT_WATCH_MS;
|
|
155
|
+
const interval = options.interval ?? defaultInterval;
|
|
156
|
+
const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
157
|
+
const messageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
|
|
158
|
+
|
|
159
|
+
let store: BusStore | undefined;
|
|
160
|
+
let plansRoot: string | undefined;
|
|
161
|
+
let record: SessionRecord | undefined;
|
|
162
|
+
let joined = false;
|
|
163
|
+
let stopHeartbeat: (() => void) | undefined;
|
|
164
|
+
let starting: Promise<void> | undefined;
|
|
165
|
+
|
|
166
|
+
let armed = false;
|
|
167
|
+
let wake = false;
|
|
168
|
+
let stopTicker: (() => void) | undefined;
|
|
169
|
+
let pendingMail: BusMessage[] = [];
|
|
170
|
+
/** Ids already queued, reported, or consumed; a broadcast shares one id. */
|
|
171
|
+
let reported = new Set<string>();
|
|
172
|
+
let onWake: (() => void) | undefined;
|
|
173
|
+
|
|
174
|
+
const ensureStarted = async (context: ExtensionContext): Promise<void> => {
|
|
175
|
+
if (starting) return starting;
|
|
176
|
+
starting = (async () => {
|
|
177
|
+
// Canonical once, here: the root git reports is a real path, so an unresolved
|
|
178
|
+
// cwd does not contain the paths a caller resolves against it.
|
|
179
|
+
const cwd = realPathOfNearest(context.cwd);
|
|
180
|
+
const repo = options.repo ?? (await repoKeyFor(pi, cwd));
|
|
181
|
+
const dir = options.dir ?? join(getAgentDir(), "session-bus", repo.key);
|
|
182
|
+
plansRoot = options.plans ?? plansDir(getAgentDir(), repo.key);
|
|
183
|
+
store = createBusStore({ dir });
|
|
184
|
+
await store.ensure();
|
|
185
|
+
const id = context.sessionManager.getSessionId();
|
|
186
|
+
const mode = sessionMode(context.mode);
|
|
187
|
+
record = {
|
|
188
|
+
id,
|
|
189
|
+
label: defaultLabel(sessionName(context), cwd, id),
|
|
190
|
+
pid: process.pid,
|
|
191
|
+
cwd,
|
|
192
|
+
repoRoot: repo.root,
|
|
193
|
+
mode,
|
|
194
|
+
startedAt: now(),
|
|
195
|
+
lastSeen: now(),
|
|
196
|
+
};
|
|
197
|
+
if (isAutoJoinMode(context.mode)) await joinBus();
|
|
198
|
+
})();
|
|
199
|
+
try {
|
|
200
|
+
await starting;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
starting = undefined;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
async function joinBus(): Promise<void> {
|
|
208
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
209
|
+
if (joined) return;
|
|
210
|
+
const used = await takenLabels(store, record.id);
|
|
211
|
+
if (used.has(record.label.toLowerCase())) record.label = suffixLabel(record.label, record.id);
|
|
212
|
+
record.lastSeen = now();
|
|
213
|
+
await store.writeSession(record);
|
|
214
|
+
await settleLabel();
|
|
215
|
+
joined = true;
|
|
216
|
+
if (heartbeatMs > 0) {
|
|
217
|
+
stopHeartbeat = interval(heartbeatMs, () => {
|
|
218
|
+
void (async () => {
|
|
219
|
+
if (!record || !store) return;
|
|
220
|
+
record.lastSeen = now();
|
|
221
|
+
await store.writeSession(record);
|
|
222
|
+
// A heartbeat that resumed after a long pause can find its label taken; the
|
|
223
|
+
// tick is where a revived session notices and yields, after its own write.
|
|
224
|
+
await settleLabel();
|
|
225
|
+
})().catch(() => {});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The labels other live sessions hold: a crashed session's label is not taken. */
|
|
231
|
+
async function takenLabels(bus: BusStore, mine: string): Promise<Set<string>> {
|
|
232
|
+
const at = now();
|
|
233
|
+
const sessions = await bus.sessions();
|
|
234
|
+
return new Set(
|
|
235
|
+
sessions
|
|
236
|
+
.filter((session) => session.id !== mine && isLive(session, at, sessionTtlMs))
|
|
237
|
+
.map((session) => session.label.toLowerCase()),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A collision is settled by yielding, never by comparing session ids: a session
|
|
243
|
+
* that keeps the label does not look again, and two of them can each decide that.
|
|
244
|
+
* Every writer that observes a rival yields, and the record written last always
|
|
245
|
+
* observes the other, so a collision ends with two distinct labels.
|
|
246
|
+
*/
|
|
247
|
+
async function settleLabel(): Promise<void> {
|
|
248
|
+
if (!store || !record) return;
|
|
249
|
+
const me = record;
|
|
250
|
+
let yields = 0;
|
|
251
|
+
while (yields < 3 && (await takenLabels(store, me.id)).has(me.label.toLowerCase())) {
|
|
252
|
+
me.label = suffixLabel(me.label, me.id);
|
|
253
|
+
yields += 1;
|
|
254
|
+
}
|
|
255
|
+
if (yields === 0) return;
|
|
256
|
+
me.lastSeen = now();
|
|
257
|
+
await store.writeSession(me);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function prepare(): Promise<void> {
|
|
261
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
262
|
+
await joinBus();
|
|
263
|
+
// The caller is active by definition; refresh it before pruning so a quiet
|
|
264
|
+
// session that finally calls a tool does not delete its own record.
|
|
265
|
+
record.lastSeen = now();
|
|
266
|
+
await store.writeSession(record);
|
|
267
|
+
const sessions = await store.sessions();
|
|
268
|
+
const active = new Set(
|
|
269
|
+
sessions.filter((session) => isLive(session, now(), sessionTtlMs)).map((session) => session.id),
|
|
270
|
+
);
|
|
271
|
+
if (record) active.add(record.id);
|
|
272
|
+
await store.prune({ now: now(), activeIds: active, sessionTtlMs, messageTtlMs });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function currentRecord(): Promise<SessionRecord> {
|
|
276
|
+
await prepare();
|
|
277
|
+
if (!record) throw new Error("session bus is not started.");
|
|
278
|
+
return record;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The stored form of the paths a caller passed: validated, then keyed by target. */
|
|
282
|
+
/**
|
|
283
|
+
* The stored form of paths a plan wrote relative to the repository.
|
|
284
|
+
*
|
|
285
|
+
* A unit's paths are repository-relative by construction, so they resolve
|
|
286
|
+
* against the repo root and not the session's cwd: a session started in a
|
|
287
|
+
* subdirectory would otherwise claim `packages/web/src/core` for a unit that
|
|
288
|
+
* owns `src/core`.
|
|
289
|
+
*/
|
|
290
|
+
function rootedPaths(me: SessionRecord, paths: readonly string[]): string[] {
|
|
291
|
+
const keyed = canonicalPaths(
|
|
292
|
+
me.repoRoot,
|
|
293
|
+
paths.map((path) => path.replace(/^\/+/, "")),
|
|
294
|
+
);
|
|
295
|
+
if ("error" in keyed) throw new Error(keyed.error);
|
|
296
|
+
return keyed.paths;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function storedPaths(me: SessionRecord, raw: unknown): string[] {
|
|
300
|
+
const normalized = normalizePaths(me.repoRoot, me.cwd, raw);
|
|
301
|
+
if ("error" in normalized) throw new Error(normalized.error);
|
|
302
|
+
const keyed = canonicalPaths(me.repoRoot, normalized.paths);
|
|
303
|
+
if ("error" in keyed) throw new Error(keyed.error);
|
|
304
|
+
return keyed.paths;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function withUnread(rows: readonly SessionRecord[]): Promise<Array<SessionRecord & { unread: number }>> {
|
|
308
|
+
return Promise.all(rows.map(async (session) => ({ ...session, unread: (await store?.unread(session.id)) ?? 0 })));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The bus as it stands, this session's own record included.
|
|
313
|
+
*
|
|
314
|
+
* `liveOnly` drops a session whose heartbeat went stale, and a claim held by
|
|
315
|
+
* one, without writing or deleting anything, so a board can read it every
|
|
316
|
+
* second. The prune itself stays with `prepare()` on the next tool or command
|
|
317
|
+
* call, which is therefore what the screen's next action sees.
|
|
318
|
+
*/
|
|
319
|
+
async function readStatus(me: { id: string; label: string }, liveOnly: boolean): Promise<SessionStatus> {
|
|
320
|
+
if (!store) throw new Error("session bus is not started.");
|
|
321
|
+
const records = await store.sessions();
|
|
322
|
+
const sessions = liveOnly
|
|
323
|
+
? records.filter((session) => session.id === record?.id || isLive(session, now(), sessionTtlMs))
|
|
324
|
+
: records;
|
|
325
|
+
const live = new Set(sessions.map((session) => session.id));
|
|
326
|
+
const claims = (await store.claims()).filter((claim) => !liveOnly || live.has(claim.owner));
|
|
327
|
+
// The team line is a file read plus a claim scan, so the board's repaint does
|
|
328
|
+
// not pay for it: only an explicit status call reports units.
|
|
329
|
+
const board = liveOnly || plansRoot === undefined ? undefined : newestTeam(plansRoot);
|
|
330
|
+
const units =
|
|
331
|
+
board === undefined
|
|
332
|
+
? undefined
|
|
333
|
+
: {
|
|
334
|
+
team: board.team,
|
|
335
|
+
joined: board.units.filter((unit) => unitHolder(unit, claims, claimsConflict) !== undefined).length,
|
|
336
|
+
total: board.units.length,
|
|
337
|
+
rows: board.units.map((unit) => {
|
|
338
|
+
const holder = unitHolder(unit, claims, claimsConflict);
|
|
339
|
+
return {
|
|
340
|
+
unit: unit.name,
|
|
341
|
+
...(unit.lead ? { lead: true } : {}),
|
|
342
|
+
...(holder === undefined ? {} : { holder }),
|
|
343
|
+
};
|
|
344
|
+
}),
|
|
345
|
+
};
|
|
346
|
+
return {
|
|
347
|
+
me,
|
|
348
|
+
sessions: await withUnread(sessions),
|
|
349
|
+
claims,
|
|
350
|
+
...(units ? { units } : {}),
|
|
351
|
+
...(armed ? { watching: { armed, wake } } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function dropFromPending(ids: ReadonlySet<string>): void {
|
|
356
|
+
pendingMail = pendingMail.filter((message) => !ids.has(message.id));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function markReported(ids: readonly string[]): void {
|
|
360
|
+
for (const id of ids) reported.add(id);
|
|
361
|
+
dropFromPending(new Set(ids));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Queue what arrived since the last poll, once, and wake a turn when this
|
|
366
|
+
* session asked for one.
|
|
367
|
+
*
|
|
368
|
+
* The reported set is narrowed to the mail still on disk, so it is bounded by
|
|
369
|
+
* the unread count rather than by the session's whole history; an id is unique
|
|
370
|
+
* for good, so a message that was consumed can never be queued again.
|
|
371
|
+
*/
|
|
372
|
+
async function pollOnce(): Promise<void> {
|
|
373
|
+
if (!armed || !store || !record) return;
|
|
374
|
+
const mail = await store.peek(record.id, MAX_LIMIT);
|
|
375
|
+
const unread = new Set(mail.map((message) => message.id));
|
|
376
|
+
reported = new Set([...reported].filter((id) => unread.has(id)));
|
|
377
|
+
// A message that expired or was consumed leaves the queue with its file.
|
|
378
|
+
pendingMail = pendingMail.filter((message) => unread.has(message.id));
|
|
379
|
+
const queued = new Set(pendingMail.map((message) => message.id));
|
|
380
|
+
const fresh = mail.filter((message) => !reported.has(message.id) && !queued.has(message.id));
|
|
381
|
+
if (fresh.length === 0) return;
|
|
382
|
+
pendingMail = [...pendingMail, ...fresh];
|
|
383
|
+
if (wake) onWake?.();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function disarm(): void {
|
|
387
|
+
stopTicker?.();
|
|
388
|
+
stopTicker = undefined;
|
|
389
|
+
armed = false;
|
|
390
|
+
wake = false;
|
|
391
|
+
pendingMail = [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function watch(input: { wake?: boolean; stop?: boolean }): Promise<{ armed: boolean; wake: boolean }> {
|
|
395
|
+
await currentRecord();
|
|
396
|
+
if (input.stop) {
|
|
397
|
+
disarm();
|
|
398
|
+
return { armed: false, wake: false };
|
|
399
|
+
}
|
|
400
|
+
if (!armed) {
|
|
401
|
+
armed = true;
|
|
402
|
+
wake = input.wake === true;
|
|
403
|
+
await pollOnce();
|
|
404
|
+
if (watchMs > 0) stopTicker = interval(watchMs, () => void pollOnce().catch(() => {}));
|
|
405
|
+
return { armed, wake };
|
|
406
|
+
}
|
|
407
|
+
// The mode is a setting, not an arm-time flag: an explicit `wake` changes it
|
|
408
|
+
// under a queued notice, and an omitted one keeps it, so bare `watch` never
|
|
409
|
+
// demotes an armed session.
|
|
410
|
+
if (input.wake !== undefined) wake = input.wake;
|
|
411
|
+
// Asking for wake over mail already queued is a request for that burst too;
|
|
412
|
+
// mid-turn it is suppressed here and retried once the run settles.
|
|
413
|
+
if (wake && pendingMail.length > 0) onWake?.();
|
|
414
|
+
return { armed, wake };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function rename(label: string): Promise<string> {
|
|
418
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
419
|
+
const candidate = normalizeLabel(label, record.label);
|
|
420
|
+
if ((await takenLabels(store, record.id)).has(candidate.toLowerCase())) {
|
|
421
|
+
throw new Error(`the label "${candidate}" is already in use.`);
|
|
422
|
+
}
|
|
423
|
+
record.label = candidate;
|
|
424
|
+
record.lastSeen = now();
|
|
425
|
+
await store.writeSession(record);
|
|
426
|
+
// A rename that raced another one ends on the label the bus settled on, which
|
|
427
|
+
// is what the caller hands to Pi as this session's name.
|
|
428
|
+
await settleLabel();
|
|
429
|
+
joined = true;
|
|
430
|
+
return record.label;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
async start(context) {
|
|
435
|
+
await ensureStarted(context);
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
async stop() {
|
|
439
|
+
stopHeartbeat?.();
|
|
440
|
+
stopHeartbeat = undefined;
|
|
441
|
+
disarm();
|
|
442
|
+
reported = new Set();
|
|
443
|
+
if (store && record && joined) {
|
|
444
|
+
await store.releaseAll(record.id).catch(() => {});
|
|
445
|
+
await store.removeSession(record.id).catch(() => {});
|
|
446
|
+
}
|
|
447
|
+
joined = false;
|
|
448
|
+
starting = undefined;
|
|
449
|
+
store = undefined;
|
|
450
|
+
record = undefined;
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
watch,
|
|
454
|
+
wantsWake: () => armed && wake,
|
|
455
|
+
poll: pollOnce,
|
|
456
|
+
pending: () => [...pendingMail],
|
|
457
|
+
markReported,
|
|
458
|
+
set onWake(callback: (() => void) | undefined) {
|
|
459
|
+
onWake = callback;
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
async status() {
|
|
463
|
+
const me = await currentRecord();
|
|
464
|
+
return readStatus({ id: me.id, label: me.label }, false);
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
async snapshot() {
|
|
468
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
469
|
+
return readStatus({ id: record.id, label: record.label }, true);
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
async peek() {
|
|
473
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
474
|
+
return store.peek(record.id, MAX_LIMIT);
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Release a claim by its stored path.
|
|
479
|
+
*
|
|
480
|
+
* No normalization: a claim is stored relative to the repo root of the
|
|
481
|
+
* session that took it, and resolving it again against a cwd below that root
|
|
482
|
+
* would rewrite the target (`src/api` becomes `src/src/api`). The path comes
|
|
483
|
+
* from the store and `releaseOne` still refuses a claim owned by someone
|
|
484
|
+
* else, so this cannot touch a peer's claim.
|
|
485
|
+
*/
|
|
486
|
+
async releaseStored(path) {
|
|
487
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
488
|
+
return store.release(path, record.id);
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
async send(input) {
|
|
492
|
+
const me = await currentRecord();
|
|
493
|
+
if (!store) throw new Error("session bus is not started.");
|
|
494
|
+
const text = normalizeText(input.text);
|
|
495
|
+
if ("error" in text) throw new Error(text.error);
|
|
496
|
+
const kind = normalizeKind(input.kind);
|
|
497
|
+
if (input.to.trim() === "*" && kind !== "event") throw new Error('a broadcast needs kind: "event".');
|
|
498
|
+
const sessions = await store.sessions();
|
|
499
|
+
const resolved = resolveRecipients(sessions, input.to, me.id);
|
|
500
|
+
if ("error" in resolved) throw new Error(resolved.error);
|
|
501
|
+
let paths: string[] | undefined;
|
|
502
|
+
if (input.paths !== undefined) paths = storedPaths(me, input.paths);
|
|
503
|
+
const id = messageId(now());
|
|
504
|
+
for (const recipient of resolved.recipients) {
|
|
505
|
+
const message: BusMessage = {
|
|
506
|
+
id,
|
|
507
|
+
from: me.id,
|
|
508
|
+
fromLabel: me.label,
|
|
509
|
+
to: recipient.id,
|
|
510
|
+
toLabel: recipient.label,
|
|
511
|
+
kind,
|
|
512
|
+
text: text.text,
|
|
513
|
+
...(paths ? { paths } : {}),
|
|
514
|
+
at: now(),
|
|
515
|
+
};
|
|
516
|
+
await store.writeMessage(recipient.id, message);
|
|
517
|
+
}
|
|
518
|
+
return { delivered: resolved.recipients.map((recipient) => recipient.label), messageId: id };
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
async inbox(limit) {
|
|
522
|
+
const me = await currentRecord();
|
|
523
|
+
if (!store) throw new Error("session bus is not started.");
|
|
524
|
+
const messages = await store.inbox(me.id, normalizeLimit(limit));
|
|
525
|
+
// Mail read before its notice fired is never announced afterwards.
|
|
526
|
+
markReported(messages.map((message) => message.id));
|
|
527
|
+
return messages;
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
async claim(rawPaths) {
|
|
531
|
+
const me = await currentRecord();
|
|
532
|
+
if (!store) throw new Error("session bus is not started.");
|
|
533
|
+
return store.claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
async release(rawPaths) {
|
|
537
|
+
const me = await currentRecord();
|
|
538
|
+
if (!store) throw new Error("session bus is not started.");
|
|
539
|
+
const released: string[] = [];
|
|
540
|
+
const notOwned: string[] = [];
|
|
541
|
+
for (const path of storedPaths(me, rawPaths)) {
|
|
542
|
+
if (await store.release(path, me.id)) released.push(path);
|
|
543
|
+
else notOwned.push(path);
|
|
544
|
+
}
|
|
545
|
+
return { released, notOwned };
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
async join(input) {
|
|
549
|
+
const me = await currentRecord();
|
|
550
|
+
if (!store) throw new Error("session bus is not started.");
|
|
551
|
+
if (!splitEnabled(me.cwd, me.repoRoot)) throw new Error(SPLIT_OFF_NOTE);
|
|
552
|
+
const unit = resolveUnit(plansRoot ?? "", input, "join");
|
|
553
|
+
const claimed = await store.claimAll(rootedPaths(me, unit.paths), me.id, me.label, now());
|
|
554
|
+
return { team: unit.team, unit: unit.name, ...claimed };
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
async leave(input) {
|
|
558
|
+
const me = await currentRecord();
|
|
559
|
+
if (!store) throw new Error("session bus is not started.");
|
|
560
|
+
const paths =
|
|
561
|
+
input.team === undefined && input.unit === undefined
|
|
562
|
+
? (await store.claims()).filter((claim) => claim.owner === me.id).map((claim) => claim.path)
|
|
563
|
+
: rootedPaths(me, resolveUnit(plansRoot ?? "", input, "leave").paths);
|
|
564
|
+
const released: string[] = [];
|
|
565
|
+
const notOwned: string[] = [];
|
|
566
|
+
for (const path of paths) {
|
|
567
|
+
if (await store.release(path, me.id)) released.push(path);
|
|
568
|
+
else notOwned.push(path);
|
|
569
|
+
}
|
|
570
|
+
return { released, notOwned };
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
rename,
|
|
574
|
+
};
|
|
575
|
+
}
|