@gavin-hu/my-pi-agent 0.18.0 → 0.20.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 +66 -52
- package/README.zh-CN.md +56 -46
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +507 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
- 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 +561 -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} +62 -52
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
- 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 +298 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +254 -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} +260 -163
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
- package/extensions/board/commands.ts +105 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +170 -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 +388 -0
- package/extensions/board/result.ts +108 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +90 -0
- package/extensions/board/seat.ts +28 -0
- package/extensions/board/snapshot.ts +42 -0
- package/extensions/board/tools.ts +444 -0
- package/extensions/board/transport/router.ts +91 -0
- package/extensions/board/transport/server.ts +138 -0
- package/extensions/board/types.ts +121 -0
- package/extensions/board/view/client.ts +51 -0
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +125 -0
- package/extensions/board/view/live.ts +231 -0
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +118 -0
- package/extensions/board/view/render.ts +519 -0
- package/extensions/board/view/styles.ts +228 -0
- package/extensions/board/view/team.ts +327 -0
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +67 -7
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +75 -43
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/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/check/commands.ts +2 -1
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +12 -0
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/files/tree.ts +101 -0
- 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 -17
- 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/view/emit.ts +51 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
- 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/job/runtime.ts +4 -4
- 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 +185 -52
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +23 -5
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +27 -34
- package/extensions/plan/policy.ts +30 -17
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +52 -11
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +20 -95
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +15 -8
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -8
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +3 -58
- package/extensions/plan/web-settle.ts +194 -0
- 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} +15 -14
- 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 +11 -5
- package/extensions/session-bus/runtime.ts +220 -267
- package/extensions/session-bus/store.ts +55 -7
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +5 -5
- package/extensions/session-bus/types.ts +23 -14
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +145 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +119 -0
- package/extensions/team/index.ts +34 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +267 -0
- package/extensions/team/types.ts +65 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- 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 +106 -251
- 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/intake.ts +245 -0
- 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/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +18 -290
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +11 -8
- package/lib/board/card.ts +479 -0
- package/lib/board/links.ts +141 -0
- package/lib/board/page-pointer.ts +310 -0
- package/lib/board/round-text.ts +81 -0
- package/lib/board/round.ts +94 -0
- package/lib/board/rules.ts +437 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +497 -0
- package/lib/board/view.ts +234 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +35 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +237 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +135 -0
- package/lib/http/token.ts +38 -0
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +25 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +201 -0
- package/lib/session-tool.ts +184 -0
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +38 -2
- package/lib/tui/glyphs.ts +3 -2
- package/lib/web/README.md +57 -4
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/components.ts +5 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -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/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/file-browse/README.md +0 -506
- package/extensions/file-browse/files.ts +0 -564
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/job/process.ts +0 -35
- 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,32 @@ 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 {
|
|
44
|
+
BUS_DIR_NAME,
|
|
45
|
+
type RosterSeams,
|
|
46
|
+
type RosterState,
|
|
47
|
+
rosterStateOf,
|
|
48
|
+
SESSION_TTL_MS,
|
|
49
|
+
} from "../../lib/session-roster.ts";
|
|
50
|
+
import { isPidAlive, type LivenessFn, startTokenOf, type StartTokenFn } from "../../lib/spawn.ts";
|
|
45
51
|
|
|
46
52
|
export interface SessionBusOptions {
|
|
47
53
|
now?: () => number;
|
|
48
54
|
/** Test seam: use this bus directory instead of the agent-dir layout. */
|
|
49
55
|
dir?: string;
|
|
56
|
+
/** Test seam: the process id this runtime registers as, so two can share one session id. */
|
|
57
|
+
pid?: number;
|
|
58
|
+
/** Test seam: whether a pid is running, so a suite never depends on the host's pids. */
|
|
59
|
+
alive?: LivenessFn;
|
|
60
|
+
/** Test seam: the start-time seam the record is stamped with. */
|
|
61
|
+
startTokenFn?: StartTokenFn;
|
|
50
62
|
/** Test seam: skip git and use this repository identity. */
|
|
51
63
|
repo?: { root: string; key: string };
|
|
52
|
-
/** Test seam: read teams from this plan store instead of the agent-dir layout. */
|
|
53
|
-
plans?: string;
|
|
54
64
|
/** Heartbeat interval; 0 disables it (tests). */
|
|
55
65
|
heartbeatMs?: number;
|
|
56
66
|
/** Inbox-watch poll interval; 0 disables it (tests). */
|
|
@@ -80,16 +90,16 @@ export interface SessionBus {
|
|
|
80
90
|
inbox(limit?: number): Promise<BusMessage[]>;
|
|
81
91
|
claim(paths: unknown): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
|
|
82
92
|
release(paths: unknown): Promise<{ released: string[]; notOwned: string[] }>;
|
|
83
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Join the team: join the bus, name this session when given a label, and listen for
|
|
95
|
+
* mail unless `listen` is false. One action, because they are one act — a session on
|
|
96
|
+
* the bus that will not hear mail is on it, but not on the team.
|
|
97
|
+
*/
|
|
84
98
|
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 }>;
|
|
99
|
+
label?: string;
|
|
100
|
+
wake?: boolean;
|
|
101
|
+
listen?: boolean;
|
|
102
|
+
}): Promise<{ label: string; watching: WatchingState }>;
|
|
93
103
|
/** Whether the armed watch asked for a triggered turn; the settle retry reads it. */
|
|
94
104
|
wantsWake(): boolean;
|
|
95
105
|
/** Read the inbox without consuming it, queueing what is new; no-op when unarmed. */
|
|
@@ -103,73 +113,58 @@ export interface SessionBus {
|
|
|
103
113
|
|
|
104
114
|
const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
105
115
|
const DEFAULT_WATCH_MS = 2_000;
|
|
106
|
-
const DEFAULT_SESSION_TTL_MS = 10 * 60_000;
|
|
107
116
|
const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60_000;
|
|
108
117
|
function messageId(now: number): string {
|
|
109
118
|
return `${now}-${randomBytes(4).toString("hex")}`;
|
|
110
119
|
}
|
|
111
120
|
|
|
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
121
|
/** A label with this session's short id appended, inside the label budget. */
|
|
118
122
|
function suffixLabel(label: string, id: string): string {
|
|
119
123
|
const suffix = `-${shortId(id)}`;
|
|
120
124
|
return `${label.slice(0, MAX_LABEL - suffix.length)}${suffix}`;
|
|
121
125
|
}
|
|
122
126
|
|
|
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
127
|
export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions = {}): SessionBus {
|
|
152
128
|
const now = options.now ?? Date.now;
|
|
153
129
|
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
154
130
|
const watchMs = options.watchMs ?? DEFAULT_WATCH_MS;
|
|
155
131
|
const interval = options.interval ?? defaultInterval;
|
|
156
|
-
const
|
|
132
|
+
const pid = options.pid ?? process.pid;
|
|
133
|
+
const seams: RosterSeams = { alive: options.alive ?? isPidAlive, token: options.startTokenFn ?? startTokenOf };
|
|
134
|
+
// Asked once, here: a reused pid is settled from the record afterwards, so no
|
|
135
|
+
// heartbeat tick spawns `ps`.
|
|
136
|
+
const processToken = (seams.token ?? startTokenOf)(pid);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What one record says about its session.
|
|
140
|
+
*
|
|
141
|
+
* The rule is the roster's, not a copy: the bus writes these records and the page
|
|
142
|
+
* reads them, and a second copy of the heartbeat rule is how a dead session keeps its
|
|
143
|
+
* label and its claims while the screen already says it is gone.
|
|
144
|
+
*/
|
|
145
|
+
function stateOf(session: SessionRecord, at = now()): RosterState {
|
|
146
|
+
return rosterStateOf(session, at, sessionTtlMs, seams);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether a record is a session that can still answer. */
|
|
150
|
+
function isLive(session: SessionRecord, at = now()): boolean {
|
|
151
|
+
return stateOf(session, at) === "live";
|
|
152
|
+
}
|
|
153
|
+
const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
|
|
157
154
|
const messageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
|
|
158
155
|
|
|
159
156
|
let store: BusStore | undefined;
|
|
160
|
-
let plansRoot: string | undefined;
|
|
161
157
|
let record: SessionRecord | undefined;
|
|
162
158
|
let joined = false;
|
|
163
159
|
let stopHeartbeat: (() => void) | undefined;
|
|
164
160
|
let starting: Promise<void> | undefined;
|
|
165
161
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
let onWake: (() => void) | undefined;
|
|
162
|
+
/** The watch reads this session's own inbox; nothing is consumed or marked. */
|
|
163
|
+
const mailWatch = createMailWatch({
|
|
164
|
+
peek: async () => (store && record ? store.peek(record.id, MAX_LIMIT) : []),
|
|
165
|
+
interval,
|
|
166
|
+
watchMs,
|
|
167
|
+
});
|
|
173
168
|
|
|
174
169
|
const ensureStarted = async (context: ExtensionContext): Promise<void> => {
|
|
175
170
|
if (starting) return starting;
|
|
@@ -178,23 +173,24 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
178
173
|
// cwd does not contain the paths a caller resolves against it.
|
|
179
174
|
const cwd = realPathOfNearest(context.cwd);
|
|
180
175
|
const repo = options.repo ?? (await repoKeyFor(pi, cwd));
|
|
181
|
-
const dir = options.dir ?? join(getAgentDir(),
|
|
182
|
-
plansRoot = options.plans ?? plansDir(getAgentDir(), repo.key);
|
|
176
|
+
const dir = options.dir ?? join(getAgentDir(), BUS_DIR_NAME, repo.key);
|
|
183
177
|
store = createBusStore({ dir });
|
|
184
178
|
await store.ensure();
|
|
185
179
|
const id = context.sessionManager.getSessionId();
|
|
186
180
|
const mode = sessionMode(context.mode);
|
|
187
181
|
record = {
|
|
188
182
|
id,
|
|
189
|
-
label: defaultLabel(
|
|
190
|
-
pid
|
|
183
|
+
label: defaultLabel(sessionNameOf(context), cwd, id),
|
|
184
|
+
pid,
|
|
191
185
|
cwd,
|
|
192
186
|
repoRoot: repo.root,
|
|
193
187
|
mode,
|
|
194
188
|
startedAt: now(),
|
|
195
189
|
lastSeen: now(),
|
|
190
|
+
// Asked once, here: a reused pid is settled from the record, not by this session
|
|
191
|
+
// polling `ps` for its own heartbeat.
|
|
192
|
+
...(processToken === undefined ? {} : { startToken: processToken }),
|
|
196
193
|
};
|
|
197
|
-
if (isAutoJoinMode(context.mode)) await joinBus();
|
|
198
194
|
})();
|
|
199
195
|
try {
|
|
200
196
|
await starting;
|
|
@@ -213,29 +209,38 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
213
209
|
await store.writeSession(record);
|
|
214
210
|
await settleLabel();
|
|
215
211
|
joined = true;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
212
|
+
armHeartbeat();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Start the heartbeat once, whoever joins: `joinBus` on the first addressable
|
|
217
|
+
* action, and `rename`, which is a join of its own.
|
|
218
|
+
*/
|
|
219
|
+
function armHeartbeat(): void {
|
|
220
|
+
if (heartbeatMs <= 0 || stopHeartbeat) return;
|
|
221
|
+
stopHeartbeat = interval(heartbeatMs, () => {
|
|
222
|
+
void (async () => {
|
|
223
|
+
if (!record || !store) return;
|
|
224
|
+
record.lastSeen = now();
|
|
225
|
+
await store.writeSession(record);
|
|
226
|
+
// A heartbeat that resumed after a long pause can find its label taken; the
|
|
227
|
+
// tick is where a revived session notices and yields, after its own write.
|
|
228
|
+
await settleLabel();
|
|
229
|
+
})().catch(() => {});
|
|
230
|
+
});
|
|
228
231
|
}
|
|
229
232
|
|
|
230
233
|
/** The labels other live sessions hold: a crashed session's label is not taken. */
|
|
231
|
-
async function takenLabels(bus: BusStore, mine: string): Promise<
|
|
234
|
+
async function takenLabels(bus: BusStore, mine: string): Promise<Map<string, SessionRecord>> {
|
|
232
235
|
const at = now();
|
|
233
236
|
const sessions = await bus.sessions();
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
const taken = new Map<string, SessionRecord>();
|
|
238
|
+
// The record, not just the string: a refusal names the session that holds the label,
|
|
239
|
+
// which a set of lowercase labels could not do.
|
|
240
|
+
for (const session of sessions) {
|
|
241
|
+
if (session.id !== mine && isLive(session, at)) taken.set(session.label.toLowerCase(), session);
|
|
242
|
+
}
|
|
243
|
+
return taken;
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
/**
|
|
@@ -257,6 +262,27 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
257
262
|
await store.writeSession(me);
|
|
258
263
|
}
|
|
259
264
|
|
|
265
|
+
/**
|
|
266
|
+
* What a prune must keep, and what it must let go.
|
|
267
|
+
*
|
|
268
|
+
* `live` is who can still answer, and this session is active by definition. `gone` is
|
|
269
|
+
* who cannot: their rows survive on the heartbeat's clock, but the paths they held are
|
|
270
|
+
* nobody's, which is the half a lead waits for.
|
|
271
|
+
*/
|
|
272
|
+
async function pruneSets(): Promise<{ live: Set<string>; gone: Set<string> }> {
|
|
273
|
+
if (!store) throw new Error("session bus is not started.");
|
|
274
|
+
const at = now();
|
|
275
|
+
const live = new Set<string>();
|
|
276
|
+
const gone = new Set<string>();
|
|
277
|
+
for (const session of await store.sessions()) {
|
|
278
|
+
const state = stateOf(session, at);
|
|
279
|
+
if (state === "live") live.add(session.id);
|
|
280
|
+
else if (state === "gone") gone.add(session.id);
|
|
281
|
+
}
|
|
282
|
+
if (record) live.add(record.id);
|
|
283
|
+
return { live, gone };
|
|
284
|
+
}
|
|
285
|
+
|
|
260
286
|
async function prepare(): Promise<void> {
|
|
261
287
|
if (!store || !record) throw new Error("session bus is not started.");
|
|
262
288
|
await joinBus();
|
|
@@ -264,12 +290,14 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
264
290
|
// session that finally calls a tool does not delete its own record.
|
|
265
291
|
record.lastSeen = now();
|
|
266
292
|
await store.writeSession(record);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
293
|
+
const sets = await pruneSets();
|
|
294
|
+
await store.prune({
|
|
295
|
+
now: now(),
|
|
296
|
+
activeIds: sets.live,
|
|
297
|
+
goneIds: sets.gone,
|
|
298
|
+
sessionTtlMs,
|
|
299
|
+
messageTtlMs,
|
|
300
|
+
});
|
|
273
301
|
}
|
|
274
302
|
|
|
275
303
|
async function currentRecord(): Promise<SessionRecord> {
|
|
@@ -278,148 +306,70 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
278
306
|
return record;
|
|
279
307
|
}
|
|
280
308
|
|
|
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
309
|
function storedPaths(me: SessionRecord, raw: unknown): string[] {
|
|
300
310
|
const normalized = normalizePaths(me.repoRoot, me.cwd, raw);
|
|
301
311
|
if ("error" in normalized) throw new Error(normalized.error);
|
|
302
|
-
const keyed =
|
|
303
|
-
if ("error" in keyed) throw new Error(keyed.error);
|
|
312
|
+
const keyed = resolveClaimPaths(me.repoRoot, normalized.paths);
|
|
313
|
+
if ("error" in keyed) throw new Error(claimPathMessage(keyed.error));
|
|
304
314
|
return keyed.paths;
|
|
305
315
|
}
|
|
306
316
|
|
|
307
|
-
|
|
308
|
-
|
|
317
|
+
/** The store, for a closure that cannot see the check its caller made. */
|
|
318
|
+
function requireStore(): BusStore {
|
|
319
|
+
if (store === undefined) throw new Error("session bus is not started.");
|
|
320
|
+
return store;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function withUnread(
|
|
324
|
+
rows: readonly SessionRecord[],
|
|
325
|
+
): Promise<Array<SessionRecord & { unread: number; state: RosterState }>> {
|
|
326
|
+
const at = now();
|
|
327
|
+
return Promise.all(
|
|
328
|
+
rows.map(async (session) => ({
|
|
329
|
+
...session,
|
|
330
|
+
unread: (await store?.unread(session.id)) ?? 0,
|
|
331
|
+
state: stateOf(session, at),
|
|
332
|
+
})),
|
|
333
|
+
);
|
|
309
334
|
}
|
|
310
335
|
|
|
311
336
|
/**
|
|
312
337
|
* The bus as it stands, this session's own record included.
|
|
313
338
|
*
|
|
314
|
-
* `liveOnly` drops a session whose heartbeat went stale, and
|
|
315
|
-
*
|
|
316
|
-
* second.
|
|
317
|
-
*
|
|
339
|
+
* `liveOnly` drops a session whose heartbeat went stale, and the claims of one whose
|
|
340
|
+
* process is gone, without writing or deleting anything, so a board can read it every
|
|
341
|
+
* second. A gone session stays listed — a lead needs a face for the paths it still
|
|
342
|
+
* holds — but its claims are nobody's, so they are not drawn. The prune itself stays
|
|
343
|
+
* with `prepare()` on the next tool or command call, which is therefore what the
|
|
344
|
+
* screen's next action sees.
|
|
318
345
|
*/
|
|
319
|
-
async function readStatus(
|
|
346
|
+
async function readStatus(
|
|
347
|
+
me: { id: string; label: string; onBus: boolean },
|
|
348
|
+
liveOnly: boolean,
|
|
349
|
+
): Promise<SessionStatus> {
|
|
320
350
|
if (!store) throw new Error("session bus is not started.");
|
|
321
351
|
const records = await store.sessions();
|
|
322
352
|
const sessions = liveOnly
|
|
323
|
-
? records.filter((session) => session.id === record?.id ||
|
|
353
|
+
? records.filter((session) => session.id === record?.id || stateOf(session) !== "quiet")
|
|
324
354
|
: records;
|
|
325
|
-
const
|
|
326
|
-
const claims = (await store.claims()).filter((claim) => !liveOnly ||
|
|
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
|
-
};
|
|
355
|
+
const ours = new Set(sessions.filter((session) => stateOf(session) !== "gone").map((session) => session.id));
|
|
356
|
+
const claims = (await store.claims()).filter((claim) => !liveOnly || ours.has(claim.owner));
|
|
346
357
|
return {
|
|
347
|
-
me,
|
|
358
|
+
me: { id: me.id, label: me.label },
|
|
359
|
+
onBus: me.onBus,
|
|
348
360
|
sessions: await withUnread(sessions),
|
|
349
361
|
claims,
|
|
350
|
-
...(
|
|
351
|
-
...(armed ? { watching: { armed, wake } } : {}),
|
|
362
|
+
...(mailWatch.state().armed ? { watching: mailWatch.state() } : {}),
|
|
352
363
|
};
|
|
353
364
|
}
|
|
354
365
|
|
|
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
366
|
async function rename(label: string): Promise<string> {
|
|
418
367
|
if (!store || !record) throw new Error("session bus is not started.");
|
|
419
368
|
const candidate = normalizeLabel(label, record.label);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
369
|
+
// The bus's own answer to "who has it": the session record, named by its id, because
|
|
370
|
+
// the label it holds is the one the caller just asked for.
|
|
371
|
+
const holder = (await takenLabels(store, record.id)).get(candidate.toLowerCase());
|
|
372
|
+
if (holder) throw new Error(`the label "${candidate}" is already in use by session ${holder.id}.`);
|
|
423
373
|
record.label = candidate;
|
|
424
374
|
record.lastSeen = now();
|
|
425
375
|
await store.writeSession(record);
|
|
@@ -427,6 +377,7 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
427
377
|
// is what the caller hands to Pi as this session's name.
|
|
428
378
|
await settleLabel();
|
|
429
379
|
joined = true;
|
|
380
|
+
armHeartbeat();
|
|
430
381
|
return record.label;
|
|
431
382
|
}
|
|
432
383
|
|
|
@@ -438,11 +389,17 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
438
389
|
async stop() {
|
|
439
390
|
stopHeartbeat?.();
|
|
440
391
|
stopHeartbeat = undefined;
|
|
441
|
-
|
|
442
|
-
reported = new Set();
|
|
392
|
+
mailWatch.stop();
|
|
443
393
|
if (store && record && joined) {
|
|
444
|
-
|
|
445
|
-
|
|
394
|
+
// A `/resume` in a second pane registers the same session id twice; the record
|
|
395
|
+
// names the process that owns it, so this one releases and removes only its
|
|
396
|
+
// own. Releasing unconditionally would strip the survivor of the claims it is
|
|
397
|
+
// still working under.
|
|
398
|
+
const owner = await store.session(record.id).catch(() => undefined);
|
|
399
|
+
if (owner === undefined || owner.pid === pid) {
|
|
400
|
+
await store.releaseAll(record.id).catch(() => {});
|
|
401
|
+
await store.removeSession(record.id).catch(() => {});
|
|
402
|
+
}
|
|
446
403
|
}
|
|
447
404
|
joined = false;
|
|
448
405
|
starting = undefined;
|
|
@@ -450,23 +407,53 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
450
407
|
record = undefined;
|
|
451
408
|
},
|
|
452
409
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
410
|
+
// Join, name, and listen as one act. Order matters the way it does everywhere else
|
|
411
|
+
// here: the name is validated by `rename` before a watch is armed, so a refused
|
|
412
|
+
// label leaves both the record and the watch exactly as they were.
|
|
413
|
+
async join(input) {
|
|
414
|
+
const me = await currentRecord();
|
|
415
|
+
const label = input.label === undefined ? me.label : await rename(input.label);
|
|
416
|
+
// `listen: false` is the one way to be on the bus without mail: the record stays,
|
|
417
|
+
// so a peer can still address this session, and only the watch is turned off.
|
|
418
|
+
const watching =
|
|
419
|
+
input.listen === false ? await mailWatch.watch({ stop: true }) : await mailWatch.watch({ wake: input.wake });
|
|
420
|
+
// Written to the record so a peer reads it from disk instead of asking: this is the
|
|
421
|
+
// fact `team` reports as "not listening", and an absent field means exactly that.
|
|
422
|
+
if (watching.armed) me.watching = { armed: true, wake: watching.wake };
|
|
423
|
+
else delete me.watching;
|
|
424
|
+
await requireStore().writeSession(me);
|
|
425
|
+
return { label, watching };
|
|
426
|
+
},
|
|
427
|
+
wantsWake: () => mailWatch.wantsWake(),
|
|
428
|
+
poll: () => mailWatch.poll(),
|
|
429
|
+
pending: () => mailWatch.pending(),
|
|
430
|
+
markReported: (ids) => mailWatch.markReported(ids),
|
|
458
431
|
set onWake(callback: (() => void) | undefined) {
|
|
459
|
-
onWake = callback;
|
|
432
|
+
mailWatch.onWake = callback;
|
|
460
433
|
},
|
|
461
434
|
|
|
462
435
|
async status() {
|
|
436
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
437
|
+
// A reader does not join: an unjoined session prunes and reads, and is not
|
|
438
|
+
// listed by the act of looking. Only an addressable action joins.
|
|
439
|
+
if (!joined) {
|
|
440
|
+
const sets = await pruneSets();
|
|
441
|
+
await store.prune({
|
|
442
|
+
now: now(),
|
|
443
|
+
activeIds: sets.live,
|
|
444
|
+
goneIds: sets.gone,
|
|
445
|
+
sessionTtlMs,
|
|
446
|
+
messageTtlMs,
|
|
447
|
+
});
|
|
448
|
+
return readStatus({ id: record.id, label: record.label, onBus: false }, false);
|
|
449
|
+
}
|
|
463
450
|
const me = await currentRecord();
|
|
464
|
-
return readStatus({ id: me.id, label: me.label }, false);
|
|
451
|
+
return readStatus({ id: me.id, label: me.label, onBus: true }, false);
|
|
465
452
|
},
|
|
466
453
|
|
|
467
454
|
async snapshot() {
|
|
468
455
|
if (!store || !record) throw new Error("session bus is not started.");
|
|
469
|
-
return readStatus({ id: record.id, label: record.label }, true);
|
|
456
|
+
return readStatus({ id: record.id, label: record.label, onBus: joined }, true);
|
|
470
457
|
},
|
|
471
458
|
|
|
472
459
|
async peek() {
|
|
@@ -523,53 +510,19 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
|
|
|
523
510
|
if (!store) throw new Error("session bus is not started.");
|
|
524
511
|
const messages = await store.inbox(me.id, normalizeLimit(limit));
|
|
525
512
|
// Mail read before its notice fired is never announced afterwards.
|
|
526
|
-
markReported(messages.map((message) => message.id));
|
|
513
|
+
mailWatch.markReported(messages.map((message) => message.id));
|
|
527
514
|
return messages;
|
|
528
515
|
},
|
|
529
516
|
|
|
530
517
|
async claim(rawPaths) {
|
|
531
518
|
const me = await currentRecord();
|
|
532
|
-
|
|
533
|
-
return store.claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
|
|
519
|
+
return requireStore().claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
|
|
534
520
|
},
|
|
535
521
|
|
|
536
522
|
async release(rawPaths) {
|
|
537
523
|
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 };
|
|
524
|
+
const bus = requireStore();
|
|
525
|
+
return releaseClaimed(storedPaths(me, rawPaths), (path) => bus.release(path, me.id));
|
|
571
526
|
},
|
|
572
|
-
|
|
573
|
-
rename,
|
|
574
527
|
};
|
|
575
528
|
}
|