@gavin-hu/my-pi-agent 0.18.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 +52 -44
- package/README.zh-CN.md +46 -41
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
- 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} +61 -50
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- 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} +165 -118
- 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} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +258 -162
- 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/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +3 -20
- 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} +1 -1
- 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} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -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} +29 -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/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +147 -38
- package/extensions/plan/index.ts +6 -4
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +8 -11
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +21 -9
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/tools/exit.ts +11 -95
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +0 -8
- package/extensions/plan/web-client.ts +1 -1
- package/extensions/plan/web-page.ts +0 -7
- package/extensions/plan/web-router.ts +3 -57
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- 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 +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +4 -2
- package/extensions/session-bus/runtime.ts +129 -238
- package/extensions/session-bus/store.ts +42 -6
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +4 -4
- package/extensions/session-bus/types.ts +13 -13
- 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/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} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- 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} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- 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} +8 -0
- 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} +72 -28
- 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/guard.ts +3 -5
- package/lib/README.md +8 -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/serve.ts +234 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/policy.ts +12 -2
- package/lib/requirements.ts +6 -7
- 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/glyphs.ts +2 -0
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +5 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- 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 -506
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /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/{live-client.ts → view/live-client.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
|
@@ -1,32 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session-bus lifecycle and operations.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
*
|
|
6
|
-
* subagent or phone peer
|
|
4
|
+
* No mode joins on start. A session is invisible until it names itself, or takes
|
|
5
|
+
* an action that makes it addressable (`send`, `claim`, `join`, `watch`), so a
|
|
6
|
+
* subagent or phone peer never joins a team merely because the package loaded,
|
|
7
|
+
* and a fresh terminal session's board is empty. Reading — the board and
|
|
8
|
+
* `status` — never joins: a reader is not a member.
|
|
7
9
|
* The runtime owns the heartbeat, keeps no module-level state, and uses the
|
|
8
10
|
* common git directory so worktrees of one repository share one bus.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `inbox` keeps sole ownership of deleting mail, so a wake cannot eat a message
|
|
15
|
-
* the model did not read.
|
|
12
|
+
* A wake that arrives while the agent is running is queued and raised by the
|
|
13
|
+
* extension's reporter once the run settles. The watch itself — the poll, the
|
|
14
|
+
* queue, and the reported set — is `mail-watch.ts`; this module supplies one
|
|
15
|
+
* read-only peek and the actions that join.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
|
-
import { join
|
|
19
|
+
import { join } from "node:path";
|
|
20
20
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { plansDir } from "../../lib/plans/store.ts";
|
|
22
|
+
import { claimPathMessage, resolveClaimPaths } from "./resolve.ts";
|
|
23
|
+
import { realPathOfNearest } from "../../lib/path.ts";
|
|
25
24
|
import { repoKeyFor } from "../../lib/repo-key.ts";
|
|
25
|
+
import { sessionNameOf } from "../../lib/session-label.ts";
|
|
26
26
|
import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
|
|
27
27
|
import {
|
|
28
28
|
defaultLabel,
|
|
29
|
-
isAutoJoinMode,
|
|
30
29
|
MAX_LABEL,
|
|
31
30
|
MAX_LIMIT,
|
|
32
31
|
normalizeKind,
|
|
@@ -36,21 +35,21 @@ import {
|
|
|
36
35
|
normalizeText,
|
|
37
36
|
resolveRecipients,
|
|
38
37
|
sessionMode,
|
|
39
|
-
claimsConflict,
|
|
40
38
|
shortId,
|
|
41
39
|
} from "./protocol.ts";
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
40
|
+
import { createMailWatch, type WatchingState } from "./mail-watch.ts";
|
|
41
|
+
import { createBusStore, releaseClaimed, type BusStore } from "./store.ts";
|
|
44
42
|
import type { BusMessage, ClaimConflict, SessionRecord, SessionStatus } from "./types.ts";
|
|
43
|
+
import { BUS_DIR_NAME, SESSION_TTL_MS } from "../../lib/session-roster.ts";
|
|
45
44
|
|
|
46
45
|
export interface SessionBusOptions {
|
|
47
46
|
now?: () => number;
|
|
48
47
|
/** Test seam: use this bus directory instead of the agent-dir layout. */
|
|
49
48
|
dir?: string;
|
|
49
|
+
/** Test seam: the process id this runtime registers as, so two can share one session id. */
|
|
50
|
+
pid?: number;
|
|
50
51
|
/** Test seam: skip git and use this repository identity. */
|
|
51
52
|
repo?: { root: string; key: string };
|
|
52
|
-
/** Test seam: read teams from this plan store instead of the agent-dir layout. */
|
|
53
|
-
plans?: string;
|
|
54
53
|
/** Heartbeat interval; 0 disables it (tests). */
|
|
55
54
|
heartbeatMs?: number;
|
|
56
55
|
/** Inbox-watch poll interval; 0 disables it (tests). */
|
|
@@ -80,16 +79,16 @@ export interface SessionBus {
|
|
|
80
79
|
inbox(limit?: number): Promise<BusMessage[]>;
|
|
81
80
|
claim(paths: unknown): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
|
|
82
81
|
release(paths: unknown): Promise<{ released: string[]; notOwned: string[] }>;
|
|
83
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Join the team: join the bus, name this session when given a label, and listen for
|
|
84
|
+
* mail unless `listen` is false. One action, because they are one act — a session on
|
|
85
|
+
* the bus that will not hear mail is on it, but not on the team.
|
|
86
|
+
*/
|
|
84
87
|
join(input: {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 }>;
|
|
88
|
+
label?: string;
|
|
89
|
+
wake?: boolean;
|
|
90
|
+
listen?: boolean;
|
|
91
|
+
}): Promise<{ label: string; watching: WatchingState }>;
|
|
93
92
|
/** Whether the armed watch asked for a triggered turn; the settle retry reads it. */
|
|
94
93
|
wantsWake(): boolean;
|
|
95
94
|
/** Read the inbox without consuming it, queueing what is new; no-op when unarmed. */
|
|
@@ -103,7 +102,6 @@ export interface SessionBus {
|
|
|
103
102
|
|
|
104
103
|
const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
105
104
|
const DEFAULT_WATCH_MS = 2_000;
|
|
106
|
-
const DEFAULT_SESSION_TTL_MS = 10 * 60_000;
|
|
107
105
|
const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60_000;
|
|
108
106
|
function messageId(now: number): string {
|
|
109
107
|
return `${now}-${randomBytes(4).toString("hex")}`;
|
|
@@ -120,56 +118,27 @@ function suffixLabel(label: string, id: string): string {
|
|
|
120
118
|
return `${label.slice(0, MAX_LABEL - suffix.length)}${suffix}`;
|
|
121
119
|
}
|
|
122
120
|
|
|
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
121
|
export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions = {}): SessionBus {
|
|
152
122
|
const now = options.now ?? Date.now;
|
|
153
123
|
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
154
124
|
const watchMs = options.watchMs ?? DEFAULT_WATCH_MS;
|
|
155
125
|
const interval = options.interval ?? defaultInterval;
|
|
156
|
-
const
|
|
126
|
+
const pid = options.pid ?? process.pid;
|
|
127
|
+
const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
|
|
157
128
|
const messageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
|
|
158
129
|
|
|
159
130
|
let store: BusStore | undefined;
|
|
160
|
-
let plansRoot: string | undefined;
|
|
161
131
|
let record: SessionRecord | undefined;
|
|
162
132
|
let joined = false;
|
|
163
133
|
let stopHeartbeat: (() => void) | undefined;
|
|
164
134
|
let starting: Promise<void> | undefined;
|
|
165
135
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
let onWake: (() => void) | undefined;
|
|
136
|
+
/** The watch reads this session's own inbox; nothing is consumed or marked. */
|
|
137
|
+
const mailWatch = createMailWatch({
|
|
138
|
+
peek: async () => (store && record ? store.peek(record.id, MAX_LIMIT) : []),
|
|
139
|
+
interval,
|
|
140
|
+
watchMs,
|
|
141
|
+
});
|
|
173
142
|
|
|
174
143
|
const ensureStarted = async (context: ExtensionContext): Promise<void> => {
|
|
175
144
|
if (starting) return starting;
|
|
@@ -178,23 +147,21 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
178
147
|
// cwd does not contain the paths a caller resolves against it.
|
|
179
148
|
const cwd = realPathOfNearest(context.cwd);
|
|
180
149
|
const repo = options.repo ?? (await repoKeyFor(pi, cwd));
|
|
181
|
-
const dir = options.dir ?? join(getAgentDir(),
|
|
182
|
-
plansRoot = options.plans ?? plansDir(getAgentDir(), repo.key);
|
|
150
|
+
const dir = options.dir ?? join(getAgentDir(), BUS_DIR_NAME, repo.key);
|
|
183
151
|
store = createBusStore({ dir });
|
|
184
152
|
await store.ensure();
|
|
185
153
|
const id = context.sessionManager.getSessionId();
|
|
186
154
|
const mode = sessionMode(context.mode);
|
|
187
155
|
record = {
|
|
188
156
|
id,
|
|
189
|
-
label: defaultLabel(
|
|
190
|
-
pid
|
|
157
|
+
label: defaultLabel(sessionNameOf(context), cwd, id),
|
|
158
|
+
pid,
|
|
191
159
|
cwd,
|
|
192
160
|
repoRoot: repo.root,
|
|
193
161
|
mode,
|
|
194
162
|
startedAt: now(),
|
|
195
163
|
lastSeen: now(),
|
|
196
164
|
};
|
|
197
|
-
if (isAutoJoinMode(context.mode)) await joinBus();
|
|
198
165
|
})();
|
|
199
166
|
try {
|
|
200
167
|
await starting;
|
|
@@ -213,18 +180,25 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
213
180
|
await store.writeSession(record);
|
|
214
181
|
await settleLabel();
|
|
215
182
|
joined = true;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
183
|
+
armHeartbeat();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Start the heartbeat once, whoever joins: `joinBus` on the first addressable
|
|
188
|
+
* action, and `rename`, which is a join of its own.
|
|
189
|
+
*/
|
|
190
|
+
function armHeartbeat(): void {
|
|
191
|
+
if (heartbeatMs <= 0 || stopHeartbeat) return;
|
|
192
|
+
stopHeartbeat = interval(heartbeatMs, () => {
|
|
193
|
+
void (async () => {
|
|
194
|
+
if (!record || !store) return;
|
|
195
|
+
record.lastSeen = now();
|
|
196
|
+
await store.writeSession(record);
|
|
197
|
+
// A heartbeat that resumed after a long pause can find its label taken; the
|
|
198
|
+
// tick is where a revived session notices and yields, after its own write.
|
|
199
|
+
await settleLabel();
|
|
200
|
+
})().catch(() => {});
|
|
201
|
+
});
|
|
228
202
|
}
|
|
229
203
|
|
|
230
204
|
/** The labels other live sessions hold: a crashed session's label is not taken. */
|
|
@@ -257,6 +231,17 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
257
231
|
await store.writeSession(me);
|
|
258
232
|
}
|
|
259
233
|
|
|
234
|
+
/** The live session ids a prune must keep: this session is active by definition. */
|
|
235
|
+
async function liveIds(): Promise<Set<string>> {
|
|
236
|
+
if (!store) throw new Error("session bus is not started.");
|
|
237
|
+
const at = now();
|
|
238
|
+
const active = new Set(
|
|
239
|
+
(await store.sessions()).filter((session) => isLive(session, at, sessionTtlMs)).map((session) => session.id),
|
|
240
|
+
);
|
|
241
|
+
if (record) active.add(record.id);
|
|
242
|
+
return active;
|
|
243
|
+
}
|
|
244
|
+
|
|
260
245
|
async function prepare(): Promise<void> {
|
|
261
246
|
if (!store || !record) throw new Error("session bus is not started.");
|
|
262
247
|
await joinBus();
|
|
@@ -278,32 +263,20 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
278
263
|
return record;
|
|
279
264
|
}
|
|
280
265
|
|
|
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
266
|
function storedPaths(me: SessionRecord, raw: unknown): string[] {
|
|
300
267
|
const normalized = normalizePaths(me.repoRoot, me.cwd, raw);
|
|
301
268
|
if ("error" in normalized) throw new Error(normalized.error);
|
|
302
|
-
const keyed =
|
|
303
|
-
if ("error" in keyed) throw new Error(keyed.error);
|
|
269
|
+
const keyed = resolveClaimPaths(me.repoRoot, normalized.paths);
|
|
270
|
+
if ("error" in keyed) throw new Error(claimPathMessage(keyed.error));
|
|
304
271
|
return keyed.paths;
|
|
305
272
|
}
|
|
306
273
|
|
|
274
|
+
/** The store, for a closure that cannot see the check its caller made. */
|
|
275
|
+
function requireStore(): BusStore {
|
|
276
|
+
if (store === undefined) throw new Error("session bus is not started.");
|
|
277
|
+
return store;
|
|
278
|
+
}
|
|
279
|
+
|
|
307
280
|
async function withUnread(rows: readonly SessionRecord[]): Promise<Array<SessionRecord & { unread: number }>> {
|
|
308
281
|
return Promise.all(rows.map(async (session) => ({ ...session, unread: (await store?.unread(session.id)) ?? 0 })));
|
|
309
282
|
}
|
|
@@ -316,7 +289,10 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
316
289
|
* second. The prune itself stays with `prepare()` on the next tool or command
|
|
317
290
|
* call, which is therefore what the screen's next action sees.
|
|
318
291
|
*/
|
|
319
|
-
async function readStatus(
|
|
292
|
+
async function readStatus(
|
|
293
|
+
me: { id: string; label: string; onBus: boolean },
|
|
294
|
+
liveOnly: boolean,
|
|
295
|
+
): Promise<SessionStatus> {
|
|
320
296
|
if (!store) throw new Error("session bus is not started.");
|
|
321
297
|
const records = await store.sessions();
|
|
322
298
|
const sessions = liveOnly
|
|
@@ -324,96 +300,15 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
324
300
|
: records;
|
|
325
301
|
const live = new Set(sessions.map((session) => session.id));
|
|
326
302
|
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
303
|
return {
|
|
347
|
-
me,
|
|
304
|
+
me: { id: me.id, label: me.label },
|
|
305
|
+
onBus: me.onBus,
|
|
348
306
|
sessions: await withUnread(sessions),
|
|
349
307
|
claims,
|
|
350
|
-
...(
|
|
351
|
-
...(armed ? { watching: { armed, wake } } : {}),
|
|
308
|
+
...(mailWatch.state().armed ? { watching: mailWatch.state() } : {}),
|
|
352
309
|
};
|
|
353
310
|
}
|
|
354
311
|
|
|
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
312
|
async function rename(label: string): Promise<string> {
|
|
418
313
|
if (!store || !record) throw new Error("session bus is not started.");
|
|
419
314
|
const candidate = normalizeLabel(label, record.label);
|
|
@@ -427,6 +322,7 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
427
322
|
// is what the caller hands to Pi as this session's name.
|
|
428
323
|
await settleLabel();
|
|
429
324
|
joined = true;
|
|
325
|
+
armHeartbeat();
|
|
430
326
|
return record.label;
|
|
431
327
|
}
|
|
432
328
|
|
|
@@ -438,11 +334,17 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
438
334
|
async stop() {
|
|
439
335
|
stopHeartbeat?.();
|
|
440
336
|
stopHeartbeat = undefined;
|
|
441
|
-
|
|
442
|
-
reported = new Set();
|
|
337
|
+
mailWatch.stop();
|
|
443
338
|
if (store && record && joined) {
|
|
444
|
-
|
|
445
|
-
|
|
339
|
+
// A `/resume` in a second pane registers the same session id twice; the record
|
|
340
|
+
// names the process that owns it, so this one releases and removes only its
|
|
341
|
+
// own. Releasing unconditionally would strip the survivor of the claims it is
|
|
342
|
+
// still working under.
|
|
343
|
+
const owner = await store.session(record.id).catch(() => undefined);
|
|
344
|
+
if (owner === undefined || owner.pid === pid) {
|
|
345
|
+
await store.releaseAll(record.id).catch(() => {});
|
|
346
|
+
await store.removeSession(record.id).catch(() => {});
|
|
347
|
+
}
|
|
446
348
|
}
|
|
447
349
|
joined = false;
|
|
448
350
|
starting = undefined;
|
|
@@ -450,23 +352,46 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
450
352
|
record = undefined;
|
|
451
353
|
},
|
|
452
354
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
355
|
+
// Join, name, and listen as one act. Order matters the way it does everywhere else
|
|
356
|
+
// here: the name is validated by `rename` before a watch is armed, so a refused
|
|
357
|
+
// label leaves both the record and the watch exactly as they were.
|
|
358
|
+
async join(input) {
|
|
359
|
+
const me = await currentRecord();
|
|
360
|
+
const label = input.label === undefined ? me.label : await rename(input.label);
|
|
361
|
+
// `listen: false` is the one way to be on the bus without mail: the record stays,
|
|
362
|
+
// so a peer can still address this session, and only the watch is turned off.
|
|
363
|
+
const watching =
|
|
364
|
+
input.listen === false ? await mailWatch.watch({ stop: true }) : await mailWatch.watch({ wake: input.wake });
|
|
365
|
+
// Written to the record so a peer reads it from disk instead of asking: this is the
|
|
366
|
+
// fact `team` reports as "not listening", and an absent field means exactly that.
|
|
367
|
+
if (watching.armed) me.watching = { armed: true, wake: watching.wake };
|
|
368
|
+
else delete me.watching;
|
|
369
|
+
await requireStore().writeSession(me);
|
|
370
|
+
return { label, watching };
|
|
371
|
+
},
|
|
372
|
+
wantsWake: () => mailWatch.wantsWake(),
|
|
373
|
+
poll: () => mailWatch.poll(),
|
|
374
|
+
pending: () => mailWatch.pending(),
|
|
375
|
+
markReported: (ids) => mailWatch.markReported(ids),
|
|
458
376
|
set onWake(callback: (() => void) | undefined) {
|
|
459
|
-
onWake = callback;
|
|
377
|
+
mailWatch.onWake = callback;
|
|
460
378
|
},
|
|
461
379
|
|
|
462
380
|
async status() {
|
|
381
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
382
|
+
// A reader does not join: an unjoined session prunes and reads, and is not
|
|
383
|
+
// listed by the act of looking. Only an addressable action joins.
|
|
384
|
+
if (!joined) {
|
|
385
|
+
await store.prune({ now: now(), activeIds: await liveIds(), sessionTtlMs, messageTtlMs });
|
|
386
|
+
return readStatus({ id: record.id, label: record.label, onBus: false }, false);
|
|
387
|
+
}
|
|
463
388
|
const me = await currentRecord();
|
|
464
|
-
return readStatus({ id: me.id, label: me.label }, false);
|
|
389
|
+
return readStatus({ id: me.id, label: me.label, onBus: true }, false);
|
|
465
390
|
},
|
|
466
391
|
|
|
467
392
|
async snapshot() {
|
|
468
393
|
if (!store || !record) throw new Error("session bus is not started.");
|
|
469
|
-
return readStatus({ id: record.id, label: record.label }, true);
|
|
394
|
+
return readStatus({ id: record.id, label: record.label, onBus: joined }, true);
|
|
470
395
|
},
|
|
471
396
|
|
|
472
397
|
async peek() {
|
|
@@ -523,53 +448,19 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
523
448
|
if (!store) throw new Error("session bus is not started.");
|
|
524
449
|
const messages = await store.inbox(me.id, normalizeLimit(limit));
|
|
525
450
|
// Mail read before its notice fired is never announced afterwards.
|
|
526
|
-
markReported(messages.map((message) => message.id));
|
|
451
|
+
mailWatch.markReported(messages.map((message) => message.id));
|
|
527
452
|
return messages;
|
|
528
453
|
},
|
|
529
454
|
|
|
530
455
|
async claim(rawPaths) {
|
|
531
456
|
const me = await currentRecord();
|
|
532
|
-
|
|
533
|
-
return store.claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
|
|
457
|
+
return requireStore().claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
|
|
534
458
|
},
|
|
535
459
|
|
|
536
460
|
async release(rawPaths) {
|
|
537
461
|
const me = await currentRecord();
|
|
538
|
-
|
|
539
|
-
|
|
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 };
|
|
462
|
+
const bus = requireStore();
|
|
463
|
+
return releaseClaimed(storedPaths(me, rawPaths), (path) => bus.release(path, me.id));
|
|
571
464
|
},
|
|
572
|
-
|
|
573
|
-
rename,
|
|
574
465
|
};
|
|
575
466
|
}
|
|
@@ -11,8 +11,9 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
11
11
|
import { mkdir, link, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { asRecord, readJson } from "../../lib/config.ts";
|
|
14
|
-
import { claimsConflict } from "./
|
|
14
|
+
import { claimsConflict } from "./overlap.ts";
|
|
15
15
|
import type { BusMessage, ClaimConflict, ClaimRecord, SessionRecord } from "./types.ts";
|
|
16
|
+
import { INBOX_DIR_NAME, SESSIONS_DIR_NAME } from "../../lib/session-roster.ts";
|
|
16
17
|
|
|
17
18
|
const DIR_MODE = 0o700;
|
|
18
19
|
const FILE_MODE = 0o600;
|
|
@@ -27,6 +28,8 @@ export interface BusStore {
|
|
|
27
28
|
ensure(): Promise<void>;
|
|
28
29
|
writeSession(record: SessionRecord): Promise<void>;
|
|
29
30
|
sessions(): Promise<SessionRecord[]>;
|
|
31
|
+
/** One session's record, or undefined when it is not on disk. */
|
|
32
|
+
session(id: string): Promise<SessionRecord | undefined>;
|
|
30
33
|
removeSession(id: string): Promise<void>;
|
|
31
34
|
writeMessage(target: string, message: BusMessage): Promise<void>;
|
|
32
35
|
/** Read a recipient's messages without consuming them, oldest first. */
|
|
@@ -50,6 +53,24 @@ export interface BusStore {
|
|
|
50
53
|
}): Promise<void>;
|
|
51
54
|
}
|
|
52
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
|
+
|
|
53
74
|
const SHA = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
54
75
|
|
|
55
76
|
function isSession(value: unknown): value is SessionRecord {
|
|
@@ -63,10 +84,17 @@ function isSession(value: unknown): value is SessionRecord {
|
|
|
63
84
|
typeof entry.repoRoot === "string" &&
|
|
64
85
|
typeof entry.mode === "string" &&
|
|
65
86
|
typeof entry.startedAt === "number" &&
|
|
66
|
-
typeof entry.lastSeen === "number"
|
|
87
|
+
typeof entry.lastSeen === "number" &&
|
|
88
|
+
(entry.watching === undefined || isWatchingState(entry.watching))
|
|
67
89
|
);
|
|
68
90
|
}
|
|
69
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
|
+
|
|
70
98
|
function isMessage(value: unknown): value is BusMessage {
|
|
71
99
|
const entry = asRecord(value);
|
|
72
100
|
if (!entry) return false;
|
|
@@ -136,8 +164,8 @@ async function dropIfEmpty(dir: string): Promise<void> {
|
|
|
136
164
|
/** Create a store rooted at one repository's bus directory. */
|
|
137
165
|
export function createBusStore(options: BusStoreOptions): BusStore {
|
|
138
166
|
const { dir } = options;
|
|
139
|
-
const sessionsDir = join(dir,
|
|
140
|
-
const inboxRoot = join(dir,
|
|
167
|
+
const sessionsDir = join(dir, SESSIONS_DIR_NAME);
|
|
168
|
+
const inboxRoot = join(dir, INBOX_DIR_NAME);
|
|
141
169
|
const claimsDir = join(dir, "claims");
|
|
142
170
|
|
|
143
171
|
const sessionFile = (id: string): string => join(sessionsDir, `${SHA(id)}.json`);
|
|
@@ -270,6 +298,8 @@ export function createBusStore(options: BusStoreOptions): BusStore {
|
|
|
270
298
|
|
|
271
299
|
sessions: readSessions,
|
|
272
300
|
|
|
301
|
+
session: async (id) => readTyped(sessionFile(id), isSession),
|
|
302
|
+
|
|
273
303
|
async removeSession(id) {
|
|
274
304
|
await rm(sessionFile(id), { force: true });
|
|
275
305
|
await dropIfEmpty(inboxDir(id));
|
|
@@ -306,15 +336,21 @@ export function createBusStore(options: BusStoreOptions): BusStore {
|
|
|
306
336
|
},
|
|
307
337
|
|
|
308
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);
|
|
309
343
|
for (const session of await readSessions()) {
|
|
310
344
|
if (at - session.lastSeen > sessionTtlMs) {
|
|
311
345
|
await rm(sessionFile(session.id), { force: true });
|
|
312
346
|
await dropIfEmpty(inboxDir(session.id));
|
|
313
|
-
|
|
347
|
+
live.delete(session.id);
|
|
348
|
+
} else {
|
|
349
|
+
live.add(session.id);
|
|
314
350
|
}
|
|
315
351
|
}
|
|
316
352
|
for (const claim of await readClaims()) {
|
|
317
|
-
if (!
|
|
353
|
+
if (!live.has(claim.owner)) await rm(claimFile(claim.path), { force: true });
|
|
318
354
|
}
|
|
319
355
|
for (const name of await readdir(inboxRoot).catch(() => [] as string[])) {
|
|
320
356
|
const directory = join(inboxRoot, name);
|