@gavin-hu/my-pi-agent 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -45
- package/README.zh-CN.md +54 -42
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
- package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
- package/docs/extensions/plan.md +976 -0
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +11 -21
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
- package/extensions/file-browse/live/rev.ts +42 -0
- package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/view/chrome.ts +83 -0
- package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
- package/extensions/file-browse/view/diff-html.ts +94 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +77 -21
- package/extensions/plan/feedback.ts +409 -63
- package/extensions/plan/index.ts +7 -5
- package/extensions/plan/plan-index.ts +44 -10
- package/extensions/plan/plan-text.ts +51 -24
- package/extensions/plan/plans.ts +162 -57
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +51 -95
- package/extensions/plan/runtime.ts +22 -10
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +63 -95
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +13 -19
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +29 -15
- package/extensions/plan/types.ts +29 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +122 -178
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +96 -17
- package/extensions/plan/web-router.ts +109 -47
- package/extensions/plan/web-share-session.ts +191 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +245 -151
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +113 -0
- package/extensions/session-bus/format.ts +101 -0
- package/extensions/session-bus/index.ts +75 -0
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +86 -0
- package/extensions/session-bus/overlap.ts +36 -0
- package/extensions/session-bus/protocol.ts +104 -0
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +208 -0
- package/extensions/session-bus/rows.ts +171 -0
- package/extensions/session-bus/runtime.ts +466 -0
- package/extensions/session-bus/store.ts +365 -0
- package/extensions/session-bus/tools.ts +192 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/watch/index.ts +15 -9
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
- package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/guard.ts +3 -5
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +12 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/router.ts +40 -13
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +29 -13
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/policy.ts +12 -2
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +7 -9
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/glyphs.ts +2 -0
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +6 -1
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +27 -14
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +77 -51
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -467
- package/extensions/file-browse/server.ts +0 -134
- package/extensions/plan/README.md +0 -709
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/outbound.ts +0 -100
- package/extensions/wechat/qrcode.ts +0 -46
- package/skills/changelog/SKILL.md +0 -148
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-bus lifecycle and operations.
|
|
3
|
+
*
|
|
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.
|
|
9
|
+
* The runtime owns the heartbeat, keeps no module-level state, and uses the
|
|
10
|
+
* common git directory so worktrees of one repository share one bus.
|
|
11
|
+
*
|
|
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
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { claimPathMessage, resolveClaimPaths } from "./resolve.ts";
|
|
23
|
+
import { realPathOfNearest } from "../../lib/path.ts";
|
|
24
|
+
import { repoKeyFor } from "../../lib/repo-key.ts";
|
|
25
|
+
import { sessionNameOf } from "../../lib/session-label.ts";
|
|
26
|
+
import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
|
|
27
|
+
import {
|
|
28
|
+
defaultLabel,
|
|
29
|
+
MAX_LABEL,
|
|
30
|
+
MAX_LIMIT,
|
|
31
|
+
normalizeKind,
|
|
32
|
+
normalizeLabel,
|
|
33
|
+
normalizeLimit,
|
|
34
|
+
normalizePaths,
|
|
35
|
+
normalizeText,
|
|
36
|
+
resolveRecipients,
|
|
37
|
+
sessionMode,
|
|
38
|
+
shortId,
|
|
39
|
+
} from "./protocol.ts";
|
|
40
|
+
import { createMailWatch, type WatchingState } from "./mail-watch.ts";
|
|
41
|
+
import { createBusStore, releaseClaimed, type BusStore } from "./store.ts";
|
|
42
|
+
import type { BusMessage, ClaimConflict, SessionRecord, SessionStatus } from "./types.ts";
|
|
43
|
+
import { BUS_DIR_NAME, SESSION_TTL_MS } from "../../lib/session-roster.ts";
|
|
44
|
+
|
|
45
|
+
export interface SessionBusOptions {
|
|
46
|
+
now?: () => number;
|
|
47
|
+
/** Test seam: use this bus directory instead of the agent-dir layout. */
|
|
48
|
+
dir?: string;
|
|
49
|
+
/** Test seam: the process id this runtime registers as, so two can share one session id. */
|
|
50
|
+
pid?: number;
|
|
51
|
+
/** Test seam: skip git and use this repository identity. */
|
|
52
|
+
repo?: { root: string; key: string };
|
|
53
|
+
/** Heartbeat interval; 0 disables it (tests). */
|
|
54
|
+
heartbeatMs?: number;
|
|
55
|
+
/** Inbox-watch poll interval; 0 disables it (tests). */
|
|
56
|
+
watchMs?: number;
|
|
57
|
+
/** Test seam: the repeating timer behind the heartbeat and the poll. */
|
|
58
|
+
interval?: RepeatTimer;
|
|
59
|
+
sessionTtlMs?: number;
|
|
60
|
+
messageTtlMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SessionBus {
|
|
64
|
+
start(ctx: ExtensionContext): Promise<void>;
|
|
65
|
+
stop(): Promise<void>;
|
|
66
|
+
status(): Promise<SessionStatus>;
|
|
67
|
+
/** The live bus, read only: no record refresh, no prune. */
|
|
68
|
+
snapshot(): Promise<SessionStatus>;
|
|
69
|
+
/** This session's unread mail, read only: nothing is consumed or marked. */
|
|
70
|
+
peek(): Promise<BusMessage[]>;
|
|
71
|
+
/** Release a claim by its stored repository-relative path. */
|
|
72
|
+
releaseStored(path: string): Promise<boolean>;
|
|
73
|
+
send(input: {
|
|
74
|
+
to: string;
|
|
75
|
+
text: string;
|
|
76
|
+
kind?: string;
|
|
77
|
+
paths?: unknown;
|
|
78
|
+
}): Promise<{ delivered: string[]; messageId: string }>;
|
|
79
|
+
inbox(limit?: number): Promise<BusMessage[]>;
|
|
80
|
+
claim(paths: unknown): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
|
|
81
|
+
release(paths: unknown): Promise<{ released: string[]; notOwned: string[] }>;
|
|
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
|
+
*/
|
|
87
|
+
join(input: {
|
|
88
|
+
label?: string;
|
|
89
|
+
wake?: boolean;
|
|
90
|
+
listen?: boolean;
|
|
91
|
+
}): Promise<{ label: string; watching: WatchingState }>;
|
|
92
|
+
/** Whether the armed watch asked for a triggered turn; the settle retry reads it. */
|
|
93
|
+
wantsWake(): boolean;
|
|
94
|
+
/** Read the inbox without consuming it, queueing what is new; no-op when unarmed. */
|
|
95
|
+
poll(): Promise<void> /** Mail that arrived and has not been reported yet, oldest first. */;
|
|
96
|
+
pending(): BusMessage[];
|
|
97
|
+
/** Drop mail from the pending queue, once reported or consumed. */
|
|
98
|
+
markReported(ids: readonly string[]): void;
|
|
99
|
+
/** Set what to call when new mail arrives and this session asked to be woken. */
|
|
100
|
+
set onWake(callback: (() => void) | undefined);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
104
|
+
const DEFAULT_WATCH_MS = 2_000;
|
|
105
|
+
const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60_000;
|
|
106
|
+
function messageId(now: number): string {
|
|
107
|
+
return `${now}-${randomBytes(4).toString("hex")}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether a record's heartbeat is fresh: the rule the bus prunes and displays by. */
|
|
111
|
+
function isLive(session: SessionRecord, at: number, ttlMs: number): boolean {
|
|
112
|
+
return at - session.lastSeen <= ttlMs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A label with this session's short id appended, inside the label budget. */
|
|
116
|
+
function suffixLabel(label: string, id: string): string {
|
|
117
|
+
const suffix = `-${shortId(id)}`;
|
|
118
|
+
return `${label.slice(0, MAX_LABEL - suffix.length)}${suffix}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions = {}): SessionBus {
|
|
122
|
+
const now = options.now ?? Date.now;
|
|
123
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
124
|
+
const watchMs = options.watchMs ?? DEFAULT_WATCH_MS;
|
|
125
|
+
const interval = options.interval ?? defaultInterval;
|
|
126
|
+
const pid = options.pid ?? process.pid;
|
|
127
|
+
const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
|
|
128
|
+
const messageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
|
|
129
|
+
|
|
130
|
+
let store: BusStore | undefined;
|
|
131
|
+
let record: SessionRecord | undefined;
|
|
132
|
+
let joined = false;
|
|
133
|
+
let stopHeartbeat: (() => void) | undefined;
|
|
134
|
+
let starting: Promise<void> | undefined;
|
|
135
|
+
|
|
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
|
+
});
|
|
142
|
+
|
|
143
|
+
const ensureStarted = async (context: ExtensionContext): Promise<void> => {
|
|
144
|
+
if (starting) return starting;
|
|
145
|
+
starting = (async () => {
|
|
146
|
+
// Canonical once, here: the root git reports is a real path, so an unresolved
|
|
147
|
+
// cwd does not contain the paths a caller resolves against it.
|
|
148
|
+
const cwd = realPathOfNearest(context.cwd);
|
|
149
|
+
const repo = options.repo ?? (await repoKeyFor(pi, cwd));
|
|
150
|
+
const dir = options.dir ?? join(getAgentDir(), BUS_DIR_NAME, repo.key);
|
|
151
|
+
store = createBusStore({ dir });
|
|
152
|
+
await store.ensure();
|
|
153
|
+
const id = context.sessionManager.getSessionId();
|
|
154
|
+
const mode = sessionMode(context.mode);
|
|
155
|
+
record = {
|
|
156
|
+
id,
|
|
157
|
+
label: defaultLabel(sessionNameOf(context), cwd, id),
|
|
158
|
+
pid,
|
|
159
|
+
cwd,
|
|
160
|
+
repoRoot: repo.root,
|
|
161
|
+
mode,
|
|
162
|
+
startedAt: now(),
|
|
163
|
+
lastSeen: now(),
|
|
164
|
+
};
|
|
165
|
+
})();
|
|
166
|
+
try {
|
|
167
|
+
await starting;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
starting = undefined;
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
async function joinBus(): Promise<void> {
|
|
175
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
176
|
+
if (joined) return;
|
|
177
|
+
const used = await takenLabels(store, record.id);
|
|
178
|
+
if (used.has(record.label.toLowerCase())) record.label = suffixLabel(record.label, record.id);
|
|
179
|
+
record.lastSeen = now();
|
|
180
|
+
await store.writeSession(record);
|
|
181
|
+
await settleLabel();
|
|
182
|
+
joined = true;
|
|
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
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The labels other live sessions hold: a crashed session's label is not taken. */
|
|
205
|
+
async function takenLabels(bus: BusStore, mine: string): Promise<Set<string>> {
|
|
206
|
+
const at = now();
|
|
207
|
+
const sessions = await bus.sessions();
|
|
208
|
+
return new Set(
|
|
209
|
+
sessions
|
|
210
|
+
.filter((session) => session.id !== mine && isLive(session, at, sessionTtlMs))
|
|
211
|
+
.map((session) => session.label.toLowerCase()),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A collision is settled by yielding, never by comparing session ids: a session
|
|
217
|
+
* that keeps the label does not look again, and two of them can each decide that.
|
|
218
|
+
* Every writer that observes a rival yields, and the record written last always
|
|
219
|
+
* observes the other, so a collision ends with two distinct labels.
|
|
220
|
+
*/
|
|
221
|
+
async function settleLabel(): Promise<void> {
|
|
222
|
+
if (!store || !record) return;
|
|
223
|
+
const me = record;
|
|
224
|
+
let yields = 0;
|
|
225
|
+
while (yields < 3 && (await takenLabels(store, me.id)).has(me.label.toLowerCase())) {
|
|
226
|
+
me.label = suffixLabel(me.label, me.id);
|
|
227
|
+
yields += 1;
|
|
228
|
+
}
|
|
229
|
+
if (yields === 0) return;
|
|
230
|
+
me.lastSeen = now();
|
|
231
|
+
await store.writeSession(me);
|
|
232
|
+
}
|
|
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
|
+
|
|
245
|
+
async function prepare(): Promise<void> {
|
|
246
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
247
|
+
await joinBus();
|
|
248
|
+
// The caller is active by definition; refresh it before pruning so a quiet
|
|
249
|
+
// session that finally calls a tool does not delete its own record.
|
|
250
|
+
record.lastSeen = now();
|
|
251
|
+
await store.writeSession(record);
|
|
252
|
+
const sessions = await store.sessions();
|
|
253
|
+
const active = new Set(
|
|
254
|
+
sessions.filter((session) => isLive(session, now(), sessionTtlMs)).map((session) => session.id),
|
|
255
|
+
);
|
|
256
|
+
if (record) active.add(record.id);
|
|
257
|
+
await store.prune({ now: now(), activeIds: active, sessionTtlMs, messageTtlMs });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function currentRecord(): Promise<SessionRecord> {
|
|
261
|
+
await prepare();
|
|
262
|
+
if (!record) throw new Error("session bus is not started.");
|
|
263
|
+
return record;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function storedPaths(me: SessionRecord, raw: unknown): string[] {
|
|
267
|
+
const normalized = normalizePaths(me.repoRoot, me.cwd, raw);
|
|
268
|
+
if ("error" in normalized) throw new Error(normalized.error);
|
|
269
|
+
const keyed = resolveClaimPaths(me.repoRoot, normalized.paths);
|
|
270
|
+
if ("error" in keyed) throw new Error(claimPathMessage(keyed.error));
|
|
271
|
+
return keyed.paths;
|
|
272
|
+
}
|
|
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
|
+
|
|
280
|
+
async function withUnread(rows: readonly SessionRecord[]): Promise<Array<SessionRecord & { unread: number }>> {
|
|
281
|
+
return Promise.all(rows.map(async (session) => ({ ...session, unread: (await store?.unread(session.id)) ?? 0 })));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The bus as it stands, this session's own record included.
|
|
286
|
+
*
|
|
287
|
+
* `liveOnly` drops a session whose heartbeat went stale, and a claim held by
|
|
288
|
+
* one, without writing or deleting anything, so a board can read it every
|
|
289
|
+
* second. The prune itself stays with `prepare()` on the next tool or command
|
|
290
|
+
* call, which is therefore what the screen's next action sees.
|
|
291
|
+
*/
|
|
292
|
+
async function readStatus(
|
|
293
|
+
me: { id: string; label: string; onBus: boolean },
|
|
294
|
+
liveOnly: boolean,
|
|
295
|
+
): Promise<SessionStatus> {
|
|
296
|
+
if (!store) throw new Error("session bus is not started.");
|
|
297
|
+
const records = await store.sessions();
|
|
298
|
+
const sessions = liveOnly
|
|
299
|
+
? records.filter((session) => session.id === record?.id || isLive(session, now(), sessionTtlMs))
|
|
300
|
+
: records;
|
|
301
|
+
const live = new Set(sessions.map((session) => session.id));
|
|
302
|
+
const claims = (await store.claims()).filter((claim) => !liveOnly || live.has(claim.owner));
|
|
303
|
+
return {
|
|
304
|
+
me: { id: me.id, label: me.label },
|
|
305
|
+
onBus: me.onBus,
|
|
306
|
+
sessions: await withUnread(sessions),
|
|
307
|
+
claims,
|
|
308
|
+
...(mailWatch.state().armed ? { watching: mailWatch.state() } : {}),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function rename(label: string): Promise<string> {
|
|
313
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
314
|
+
const candidate = normalizeLabel(label, record.label);
|
|
315
|
+
if ((await takenLabels(store, record.id)).has(candidate.toLowerCase())) {
|
|
316
|
+
throw new Error(`the label "${candidate}" is already in use.`);
|
|
317
|
+
}
|
|
318
|
+
record.label = candidate;
|
|
319
|
+
record.lastSeen = now();
|
|
320
|
+
await store.writeSession(record);
|
|
321
|
+
// A rename that raced another one ends on the label the bus settled on, which
|
|
322
|
+
// is what the caller hands to Pi as this session's name.
|
|
323
|
+
await settleLabel();
|
|
324
|
+
joined = true;
|
|
325
|
+
armHeartbeat();
|
|
326
|
+
return record.label;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
async start(context) {
|
|
331
|
+
await ensureStarted(context);
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async stop() {
|
|
335
|
+
stopHeartbeat?.();
|
|
336
|
+
stopHeartbeat = undefined;
|
|
337
|
+
mailWatch.stop();
|
|
338
|
+
if (store && record && joined) {
|
|
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
|
+
}
|
|
348
|
+
}
|
|
349
|
+
joined = false;
|
|
350
|
+
starting = undefined;
|
|
351
|
+
store = undefined;
|
|
352
|
+
record = undefined;
|
|
353
|
+
},
|
|
354
|
+
|
|
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),
|
|
376
|
+
set onWake(callback: (() => void) | undefined) {
|
|
377
|
+
mailWatch.onWake = callback;
|
|
378
|
+
},
|
|
379
|
+
|
|
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
|
+
}
|
|
388
|
+
const me = await currentRecord();
|
|
389
|
+
return readStatus({ id: me.id, label: me.label, onBus: true }, false);
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
async snapshot() {
|
|
393
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
394
|
+
return readStatus({ id: record.id, label: record.label, onBus: joined }, true);
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
async peek() {
|
|
398
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
399
|
+
return store.peek(record.id, MAX_LIMIT);
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Release a claim by its stored path.
|
|
404
|
+
*
|
|
405
|
+
* No normalization: a claim is stored relative to the repo root of the
|
|
406
|
+
* session that took it, and resolving it again against a cwd below that root
|
|
407
|
+
* would rewrite the target (`src/api` becomes `src/src/api`). The path comes
|
|
408
|
+
* from the store and `releaseOne` still refuses a claim owned by someone
|
|
409
|
+
* else, so this cannot touch a peer's claim.
|
|
410
|
+
*/
|
|
411
|
+
async releaseStored(path) {
|
|
412
|
+
if (!store || !record) throw new Error("session bus is not started.");
|
|
413
|
+
return store.release(path, record.id);
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
async send(input) {
|
|
417
|
+
const me = await currentRecord();
|
|
418
|
+
if (!store) throw new Error("session bus is not started.");
|
|
419
|
+
const text = normalizeText(input.text);
|
|
420
|
+
if ("error" in text) throw new Error(text.error);
|
|
421
|
+
const kind = normalizeKind(input.kind);
|
|
422
|
+
if (input.to.trim() === "*" && kind !== "event") throw new Error('a broadcast needs kind: "event".');
|
|
423
|
+
const sessions = await store.sessions();
|
|
424
|
+
const resolved = resolveRecipients(sessions, input.to, me.id);
|
|
425
|
+
if ("error" in resolved) throw new Error(resolved.error);
|
|
426
|
+
let paths: string[] | undefined;
|
|
427
|
+
if (input.paths !== undefined) paths = storedPaths(me, input.paths);
|
|
428
|
+
const id = messageId(now());
|
|
429
|
+
for (const recipient of resolved.recipients) {
|
|
430
|
+
const message: BusMessage = {
|
|
431
|
+
id,
|
|
432
|
+
from: me.id,
|
|
433
|
+
fromLabel: me.label,
|
|
434
|
+
to: recipient.id,
|
|
435
|
+
toLabel: recipient.label,
|
|
436
|
+
kind,
|
|
437
|
+
text: text.text,
|
|
438
|
+
...(paths ? { paths } : {}),
|
|
439
|
+
at: now(),
|
|
440
|
+
};
|
|
441
|
+
await store.writeMessage(recipient.id, message);
|
|
442
|
+
}
|
|
443
|
+
return { delivered: resolved.recipients.map((recipient) => recipient.label), messageId: id };
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
async inbox(limit) {
|
|
447
|
+
const me = await currentRecord();
|
|
448
|
+
if (!store) throw new Error("session bus is not started.");
|
|
449
|
+
const messages = await store.inbox(me.id, normalizeLimit(limit));
|
|
450
|
+
// Mail read before its notice fired is never announced afterwards.
|
|
451
|
+
mailWatch.markReported(messages.map((message) => message.id));
|
|
452
|
+
return messages;
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
async claim(rawPaths) {
|
|
456
|
+
const me = await currentRecord();
|
|
457
|
+
return requireStore().claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
async release(rawPaths) {
|
|
461
|
+
const me = await currentRecord();
|
|
462
|
+
const bus = requireStore();
|
|
463
|
+
return releaseClaimed(storedPaths(me, rawPaths), (path) => bus.release(path, me.id));
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|