pi-squire 0.14.0 → 0.16.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +585 -107
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. package/src/workspace.ts +38 -90
@@ -0,0 +1,147 @@
1
+ // Read-only direct-child adapter. Results remains the task/result authority.
2
+ // Rows are keyed by native session id, never by a mutable title or launch id.
3
+ import { isStarted, locateSession, type Results, recordedName, type StartedRecorded, sessionIdOf } from "./results.ts";
4
+ import { sessionIsOwned } from "./session-owner.ts";
5
+ import type { TmuxWindow } from "./tmux.ts";
6
+ import type { Activity } from "./widget-activity.ts";
7
+ import { WidgetTitleMirrors } from "./widget-title.ts";
8
+ import { WidgetTitleFiles } from "./widget-title-file.ts";
9
+ import { windowState } from "./windows.ts";
10
+
11
+ export type DelegateRow = {
12
+ key: string;
13
+ sessionId: string;
14
+ launchId: string;
15
+ identity: string;
16
+ title: string | undefined;
17
+ state: string;
18
+ running: boolean;
19
+ window: "open" | "gone" | "unknown";
20
+ model: string;
21
+ /** Transcript mtime is a last-active hint, not proof of tool activity. */
22
+ lastActive: number | undefined;
23
+ activity?: Activity;
24
+ summary?: string;
25
+ };
26
+
27
+ /** All eligible rows remain available; only this viewport incurs metadata I/O. */
28
+ export type MetadataViewport = { offset: number; limit: number };
29
+ type Metadata = {
30
+ titles: Pick<WidgetTitleFiles, "read" | "clear">;
31
+ mirrors: Pick<WidgetTitleMirrors, "read" | "clear">;
32
+ locate: typeof locateSession;
33
+ };
34
+ type Located = { locator: string; path: string };
35
+ type Entry = { record: StartedRecorded; row: DelegateRow };
36
+
37
+ function candidates(results: Results): StartedRecorded[] {
38
+ const sessions = new Map<string, StartedRecorded>();
39
+ for (const record of results.list()) {
40
+ if (!isStarted(record)) continue;
41
+ const key: string = sessionIdOf(record);
42
+ sessions.delete(key);
43
+ sessions.set(key, record);
44
+ }
45
+ return [...sessions.values()].reverse().filter((record) => !record.closed);
46
+ }
47
+
48
+ /** Independently refreshable metadata cache, with no writes to Results or shared accounting. */
49
+ export class DirectDelegateView {
50
+ private readonly metadata: Metadata;
51
+ private readonly paths = new Map<string, Located>();
52
+
53
+ constructor(metadata: Partial<Metadata> = {}) {
54
+ this.metadata = {
55
+ titles: new WidgetTitleFiles(),
56
+ mirrors: new WidgetTitleMirrors(),
57
+ locate: locateSession,
58
+ ...metadata,
59
+ };
60
+ }
61
+
62
+ clear(): void {
63
+ this.metadata.titles.clear();
64
+ this.metadata.mirrors.clear();
65
+ this.paths.clear();
66
+ }
67
+
68
+ needsWindows(results: Results): boolean {
69
+ return candidates(results).some((record) => {
70
+ if (!record.gone || record.result) return true;
71
+ try {
72
+ return sessionIsOwned(sessionIdOf(record));
73
+ } catch {
74
+ return false;
75
+ }
76
+ });
77
+ }
78
+
79
+ read(
80
+ results: Results,
81
+ windows: TmuxWindow[] | undefined,
82
+ sessionDir: string | undefined,
83
+ viewport: MetadataViewport = { offset: 0, limit: 3 },
84
+ currentWindows: ReadonlyMap<string, { windowId: string; windowName: string }> = new Map(),
85
+ ): DelegateRow[] {
86
+ const running: Set<string> = new Set(results.running().map((record) => record.id));
87
+ const entries: Entry[] = [];
88
+ for (const saved of candidates(results)) {
89
+ const current = currentWindows.get(saved.id);
90
+ const record: StartedRecorded = current ? { ...saved, ...current } : saved;
91
+ if (record.gone && !record.result && !current) continue;
92
+ // A verified exact live pane wins over list-windows pane_dead for another active pane.
93
+ const window = current ? "open" : windowState(windows, record);
94
+ const working: boolean = running.has(record.id);
95
+ if (window === "gone" || (!working && ((!record.result && !current) || window !== "open"))) continue;
96
+ entries.push({ record, row: this.row(record, working, window) });
97
+ }
98
+ const ordered: Entry[] = [
99
+ ...entries.filter(({ row }) => row.running),
100
+ ...entries.filter(({ row }) => !row.running),
101
+ ];
102
+ return ordered.map(({ record, row }, index) => {
103
+ if (index >= viewport.offset && index < viewport.offset + viewport.limit) this.enrich(record, row, sessionDir);
104
+ return row;
105
+ });
106
+ }
107
+
108
+ private row(record: StartedRecorded, running: boolean, window: DelegateRow["window"]): DelegateRow {
109
+ const sessionId: string = sessionIdOf(record);
110
+ return {
111
+ key: sessionId,
112
+ sessionId,
113
+ launchId: record.id,
114
+ identity: recordedName(record),
115
+ title: undefined,
116
+ lastActive: undefined,
117
+ state: running ? "running" : (record.result?.status ?? (record.gone ? "missing" : "unknown")),
118
+ running,
119
+ window,
120
+ model: record.result?.finishedModel ?? record.model,
121
+ };
122
+ }
123
+
124
+ private path(record: StartedRecorded, sessionDir: string | undefined): string | undefined {
125
+ const locator: string = JSON.stringify([
126
+ sessionIdOf(record),
127
+ record.sessionPath,
128
+ record.result?.sessionPath,
129
+ record.sessionDir,
130
+ sessionDir,
131
+ ]);
132
+ const cached: Located | undefined = this.paths.get(record.id);
133
+ if (cached?.locator === locator) return cached.path;
134
+ const path: string | undefined = this.metadata.locate({ ...record }, sessionDir);
135
+ if (path) this.paths.set(record.id, { locator, path });
136
+ else this.paths.delete(record.id); // No negative cache: first transcript flush must become discoverable.
137
+ return path;
138
+ }
139
+
140
+ private enrich(record: StartedRecorded, row: DelegateRow, sessionDir: string | undefined): void {
141
+ const disk = this.metadata.titles.read(this.path(record, sessionDir), row.sessionId);
142
+ if (!disk) this.paths.delete(record.id); // Missing/replaced/mismatched files are rediscovered next refresh.
143
+ const name: string | undefined = disk?.name ?? this.metadata.mirrors.read(row.sessionId);
144
+ row.title = name && name !== row.identity ? name : undefined;
145
+ row.lastActive = disk?.modified;
146
+ }
147
+ }
@@ -0,0 +1,133 @@
1
+ // Pi 1.4.0 compatibility adapter: replace this module when Pi exposes prompt-scoped input.
2
+ // No editor/footer replacement, private focusedComponent access or fail-open shape guesses.
3
+ import { CustomEditor, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
+ import { isKeyRelease, matchesKey } from "@earendil-works/pi-tui";
5
+ import type { WidgetSelection } from "./widget-selection.ts";
6
+
7
+ type PromptUI = Pick<ExtensionUIContext, "getEditorText" | "getEditorComponent">;
8
+ type PromptState = "empty" | "text" | "other";
9
+ type FocusGetter = { getFocusedComponent(): unknown };
10
+
11
+ /** Exact class identity was checked through the installed 1.4.0 bundled CLI/jiti loader. */
12
+ export function promptState(tui: unknown, ui: PromptUI): PromptState {
13
+ try {
14
+ if (!tui || typeof (tui as Partial<FocusGetter>).getFocusedComponent !== "function") return "other";
15
+ const focused: unknown = (tui as FocusGetter).getFocusedComponent();
16
+ if (!focused || Object.getPrototypeOf(focused) !== CustomEditor.prototype) return "other";
17
+ if (ui.getEditorComponent() !== undefined) return "other";
18
+ const editor: CustomEditor = focused as CustomEditor;
19
+ if (typeof editor.isShowingAutocomplete !== "function" || editor.isShowingAutocomplete() !== false) return "other";
20
+ return ui.getEditorText() === "" ? "empty" : "text";
21
+ } catch {
22
+ return "other";
23
+ }
24
+ }
25
+
26
+ type InputUI = Pick<ExtensionUIContext, "onTerminalInput" | "getEditorText" | "getEditorComponent">;
27
+ type FocusOptions = {
28
+ ui: InputUI;
29
+ selection: WidgetSelection;
30
+ changed(): void;
31
+ open(key: string): Promise<void>;
32
+ error(error: unknown): void;
33
+ };
34
+
35
+ /** Pseudo-focus: real Pi focus stays with the prompt until Pi mounts a dashboard/dialog. */
36
+ export class WidgetFocus {
37
+ private readonly options: FocusOptions;
38
+ private tui: unknown;
39
+ private unsubscribe: (() => void) | undefined;
40
+ private busy = false;
41
+ private suspended = false;
42
+ private stopped = false;
43
+
44
+ constructor(options: FocusOptions) {
45
+ this.options = options;
46
+ }
47
+
48
+ start(): void {
49
+ if (this.stopped || this.unsubscribe) return;
50
+ this.unsubscribe = this.options.ui.onTerminalInput((data) => this.input(data));
51
+ }
52
+
53
+ attach(tui: unknown): void {
54
+ // InteractiveMode injects a stable proxy which follows regular/fullscreen replacement.
55
+ this.tui = tui;
56
+ }
57
+
58
+ get focused(): boolean {
59
+ return (
60
+ !this.stopped &&
61
+ !this.busy &&
62
+ !this.suspended &&
63
+ this.options.selection.active &&
64
+ promptState(this.tui, this.options.ui) === "empty"
65
+ );
66
+ }
67
+
68
+ suspend(suspended: boolean): void {
69
+ if (this.stopped) return;
70
+ this.suspended = suspended;
71
+ // Our own dashboard keeps selection; unrelated prompts relinquish pseudo-focus.
72
+ if (suspended && !this.busy) this.options.selection.leave();
73
+ this.options.changed();
74
+ }
75
+
76
+ stop(): void {
77
+ this.stopped = true;
78
+ this.unsubscribe?.();
79
+ this.unsubscribe = undefined;
80
+ this.tui = undefined;
81
+ }
82
+
83
+ private leave(): void {
84
+ if (!this.options.selection.active) return;
85
+ this.options.selection.leave();
86
+ this.options.changed();
87
+ }
88
+
89
+ private input(data: string): { consume: true } | undefined {
90
+ if (this.stopped || isKeyRelease(data)) return;
91
+ // Async listing/action loops temporarily return focus to the prompt. Do not
92
+ // let dashboard Escape abort a run or action letters leak into that editor.
93
+ if (this.busy) return this.pendingInput();
94
+ if (this.suspended || promptState(this.tui, this.options.ui) !== "empty") {
95
+ this.leave();
96
+ return;
97
+ }
98
+ const selection: WidgetSelection = this.options.selection;
99
+ if (!selection.active) {
100
+ if (!matchesKey(data, "down") || !selection.enter()) return;
101
+ } else if (matchesKey(data, "down")) selection.move(1);
102
+ else if (matchesKey(data, "up")) selection.move(-1);
103
+ else if (matchesKey(data, "escape")) selection.leave();
104
+ else if (matchesKey(data, "enter")) this.open();
105
+ else {
106
+ this.leave();
107
+ return;
108
+ }
109
+ this.options.changed();
110
+ return { consume: true };
111
+ }
112
+
113
+ private pendingInput(): { consume: true } | undefined {
114
+ return promptState(this.tui, this.options.ui) === "empty" ? { consume: true } : undefined;
115
+ }
116
+
117
+ private open(): void {
118
+ const key: string | undefined = this.options.selection.selectedKey;
119
+ if (key === undefined) return;
120
+ this.busy = true; // Set before invoking async custom UI; repeated Enter cannot reenter.
121
+ void Promise.resolve()
122
+ .then(() => {
123
+ if (!this.stopped) return this.options.open(key);
124
+ })
125
+ .catch((error: unknown) => {
126
+ if (!this.stopped) this.options.error(error);
127
+ })
128
+ .finally(() => {
129
+ this.busy = false;
130
+ if (!this.stopped) this.options.changed();
131
+ });
132
+ }
133
+ }
@@ -0,0 +1,31 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ export type ProcessIdentity = { pid: number; processStart: string };
4
+ export type Liveness = "live" | "dead" | "unknown";
5
+
6
+ export function processStart(pid: number): Promise<string | undefined> {
7
+ return new Promise((resolve) => {
8
+ // lstart is formatted text: publisher and reader must use the same locale/time zone.
9
+ execFile(
10
+ "ps",
11
+ ["-o", "lstart=", "-p", String(pid)],
12
+ { timeout: 1000, maxBuffer: 4096, env: { ...process.env, LC_ALL: "C", TZ: "UTC" } },
13
+ (error, stdout) => {
14
+ resolve(error ? undefined : stdout.trim() || undefined);
15
+ },
16
+ );
17
+ });
18
+ }
19
+
20
+ /** Only ESRCH or a successfully observed different start token proves a dead generation. */
21
+ export async function processLiveness(owner: ProcessIdentity): Promise<Liveness> {
22
+ try {
23
+ process.kill(owner.pid, 0);
24
+ } catch (error) {
25
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return "dead";
26
+ return "unknown";
27
+ }
28
+ const start: string | undefined = await processStart(owner.pid);
29
+ if (start === undefined) return "unknown";
30
+ return start === owner.processStart ? "live" : "dead";
31
+ }
@@ -0,0 +1,356 @@
1
+ // Private disposable telemetry, never a registry, owner lock or result store.
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, open, opendir, rename, unlink, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
+ import { matchesSessionOwner } from "./session-owner.ts";
8
+ import type { PaneLocator } from "./tmux.ts";
9
+ import { type Activity, activityText } from "./widget-activity.ts";
10
+ import { type Liveness, processLiveness, processStart } from "./widget-live-process.ts";
11
+
12
+ export const LIVE_LAUNCH_ENV = "PI_SQUIRE_LIVE_LAUNCH";
13
+ export const LIVE_MAX_BYTES = 8192;
14
+ export const LIVE_MAX_GENERATIONS = 16;
15
+ type Seed = { version: 1; sessionId: string; launchId: string };
16
+ export type LiveLocator = {
17
+ locator: PaneLocator;
18
+ generation: string;
19
+ ownerToken: string;
20
+ pid: number;
21
+ sessionFile?: string;
22
+ };
23
+ type Snapshot = {
24
+ ownerToken?: string;
25
+ locator?: PaneLocator;
26
+ sessionFile?: string;
27
+ version: 1;
28
+ sessionId: string;
29
+ launchId?: string;
30
+ generation: string;
31
+ pid: number;
32
+ processStart: string;
33
+ revision: number;
34
+ observedAt: number;
35
+ activity: Activity | null;
36
+ };
37
+
38
+ export function liveLaunch(seed: string | undefined, sessionId: string): string | undefined {
39
+ try {
40
+ if (!seed || seed.length > 4096) return;
41
+ const value: Partial<Seed> = JSON.parse(seed);
42
+ if (value.version === 1 && value.sessionId === sessionId && validString(value.launchId)) return value.launchId;
43
+ } catch {
44
+ // Missing/invalid launch correlation is unavailable, never inferred from stable identity.
45
+ }
46
+ return;
47
+ }
48
+
49
+ function validString(value: unknown): value is string {
50
+ return typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= 512;
51
+ }
52
+
53
+ export function liveSessionDir(sessionId: string, agentDir = getAgentDir()): string {
54
+ return join(agentDir, "squire", "live-sessions", createHash("sha256").update(sessionId).digest("hex"));
55
+ }
56
+
57
+ async function remove(path: string): Promise<void> {
58
+ await unlink(path).catch(() => {});
59
+ }
60
+
61
+ function validActivity(value: unknown): value is Activity | null {
62
+ if (value === null) return true;
63
+ if (!value || typeof value !== "object") return false;
64
+ const activity = value as Partial<Activity>;
65
+ return (
66
+ (activity.kind === "tools" || activity.kind === "response") &&
67
+ typeof activity.text === "string" &&
68
+ Buffer.byteLength(activity.text) <= 512
69
+ );
70
+ }
71
+
72
+ function validLocator(value: PaneLocator | undefined): boolean {
73
+ if (value === undefined) return true;
74
+ return (
75
+ !!value &&
76
+ validString(value.socket) &&
77
+ Number.isSafeInteger(value.serverPid) &&
78
+ value.serverPid > 0 &&
79
+ validString(value.serverStarted) &&
80
+ /^%[0-9]+$/.test(value.paneId) &&
81
+ /^@[0-9]+$/.test(value.windowId) &&
82
+ typeof value.windowName === "string" &&
83
+ Buffer.byteLength(value.windowName) <= 512 &&
84
+ typeof value.exited === "boolean"
85
+ );
86
+ }
87
+
88
+ function validSnapshot(value: Partial<Snapshot>, sessionId: string, generation: string): value is Snapshot {
89
+ return (
90
+ validLocator(value.locator) &&
91
+ (value.ownerToken === undefined || validString(value.ownerToken)) &&
92
+ (value.sessionFile === undefined || validString(value.sessionFile)) &&
93
+ value.version === 1 &&
94
+ value.sessionId === sessionId &&
95
+ value.generation === generation &&
96
+ validString(value.sessionId) &&
97
+ (value.launchId === undefined || validString(value.launchId)) &&
98
+ Number.isSafeInteger(value.pid) &&
99
+ (value.pid ?? 0) > 0 &&
100
+ validString(value.processStart) &&
101
+ Number.isSafeInteger(value.revision) &&
102
+ (value.revision ?? -1) >= 0 &&
103
+ typeof value.observedAt === "number" &&
104
+ Number.isFinite(value.observedAt) &&
105
+ validActivity(value.activity)
106
+ );
107
+ }
108
+
109
+ async function readSnapshot(path: string, sessionId: string, generation: string): Promise<Snapshot | undefined> {
110
+ try {
111
+ const file = await open(path, "r");
112
+ try {
113
+ const buffer = Buffer.alloc(LIVE_MAX_BYTES + 1);
114
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
115
+ if (bytesRead > LIVE_MAX_BYTES) return;
116
+ const value: unknown = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
117
+ if (value && typeof value === "object" && validSnapshot(value, sessionId, generation)) return value;
118
+ } finally {
119
+ await file.close();
120
+ }
121
+ } catch {
122
+ // Optional metadata is unavailable.
123
+ }
124
+ return;
125
+ }
126
+
127
+ function uniqueSnapshot(live: Snapshot[], uncertain: boolean): Snapshot | undefined {
128
+ return !uncertain && live.length === 1 ? live[0] : undefined;
129
+ }
130
+ function snapshotFile(name: string, file: boolean): boolean {
131
+ return file && /^[a-f0-9-]{36}\.json$/.test(name);
132
+ }
133
+
134
+ type Probe = (owner: { pid: number; processStart: string }) => Promise<Liveness>;
135
+
136
+ export class LiveActivityReader {
137
+ private readonly agentDir: string;
138
+ private readonly probe: Probe;
139
+
140
+ constructor(agentDir = getAgentDir(), probe: Probe = processLiveness) {
141
+ this.agentDir = agentDir;
142
+ this.probe = probe;
143
+ }
144
+
145
+ async read(sessionId: string, launchId: string): Promise<Activity | undefined> {
146
+ try {
147
+ // Cheap absence fast path preserves prompt initial rows; never negatively cache first publication.
148
+ if (!existsSync(liveSessionDir(sessionId, this.agentDir))) return;
149
+ const value: Snapshot | undefined = await this.readSession(sessionId);
150
+ return value ? this.project(value, launchId) : undefined;
151
+ } catch {
152
+ return;
153
+ }
154
+ }
155
+
156
+ /** Stable-session navigation is independent of launch-correlated task activity. */
157
+ async locator(sessionId: string): Promise<LiveLocator | undefined> {
158
+ try {
159
+ if (!existsSync(liveSessionDir(sessionId, this.agentDir))) return;
160
+ const value: Snapshot | undefined = await this.readSession(sessionId);
161
+ if (
162
+ !value?.locator ||
163
+ !value.ownerToken ||
164
+ value.locator.exited ||
165
+ !matchesSessionOwner(sessionId, value.pid, value.ownerToken)
166
+ )
167
+ return;
168
+ return {
169
+ locator: value.locator,
170
+ generation: value.generation,
171
+ ownerToken: value.ownerToken,
172
+ pid: value.pid,
173
+ sessionFile: value.sessionFile,
174
+ };
175
+ } catch {
176
+ return;
177
+ }
178
+ }
179
+
180
+ private async readSession(sessionId: string): Promise<Snapshot | undefined> {
181
+ const dir: string = liveSessionDir(sessionId, this.agentDir);
182
+ const directory = await opendir(dir);
183
+ const live: Snapshot[] = [];
184
+ let entries = 0;
185
+ let uncertain = false;
186
+ for await (const entry of directory) {
187
+ if (++entries > LIVE_MAX_GENERATIONS) return;
188
+ if (!snapshotFile(entry.name, entry.isFile())) continue;
189
+ const generation: string = entry.name.slice(0, -5);
190
+ const path: string = join(dir, entry.name);
191
+ const value: Snapshot | undefined = await readSnapshot(path, sessionId, generation);
192
+ if (!value) {
193
+ uncertain = true;
194
+ continue;
195
+ }
196
+ const verdict: Liveness = await this.probe(value);
197
+ if (verdict === "dead") await remove(path);
198
+ else if (verdict === "live") live.push(value);
199
+ else uncertain = true;
200
+ }
201
+ return uniqueSnapshot(live, uncertain);
202
+ }
203
+
204
+ private project(value: Snapshot, launchId: string): Activity | undefined {
205
+ if (value.launchId !== launchId || !value.activity) return;
206
+ const text: string = activityText(value.activity.text);
207
+ return text ? { kind: value.activity.kind, text } : undefined;
208
+ }
209
+ }
210
+
211
+ type PublisherOptions = {
212
+ start?: () => Promise<string | undefined>;
213
+ rename?: typeof rename;
214
+ };
215
+
216
+ /** At most one pending snapshot and one in-flight write; stop never blocks task delivery. */
217
+ export class LiveActivityPublisher {
218
+ readonly generation: string = randomUUID();
219
+ private readonly sessionId: string;
220
+ private readonly launchId: string | undefined;
221
+ private readonly dir: string;
222
+ private readonly path: string;
223
+ private readonly temporary: string;
224
+ private readonly start: Promise<string | undefined>;
225
+ private readonly replace: typeof rename;
226
+ private activity: Activity | null = null;
227
+ private locator: PaneLocator | undefined;
228
+ private locatorKey: string | undefined;
229
+ private ownerToken: string | undefined;
230
+ private sessionFile: string | undefined;
231
+ private revision = 0;
232
+ private observedAt = Date.now();
233
+ private dirty = false;
234
+ private urgent = false;
235
+ private lastWrite = 0;
236
+ private stopped = false;
237
+ private timer: ReturnType<typeof setTimeout> | undefined;
238
+ private writing: Promise<void> | undefined;
239
+
240
+ constructor(sessionId: string, launchId?: string, options: PublisherOptions = {}) {
241
+ this.sessionId = sessionId;
242
+ this.launchId = launchId;
243
+ this.dir = liveSessionDir(sessionId);
244
+ this.path = join(this.dir, `${this.generation}.json`);
245
+ this.temporary = `${this.path}.tmp`;
246
+ this.start = (options.start ?? ((): Promise<string | undefined> => processStart(process.pid)))().catch(
247
+ () => undefined,
248
+ );
249
+ this.replace = options.rename ?? rename;
250
+ }
251
+
252
+ publishLocator(locator: PaneLocator | undefined, ownerToken: string, sessionFile?: string): void {
253
+ const key: string = JSON.stringify([locator, ownerToken, sessionFile]);
254
+ if (this.stopped || key === this.locatorKey) return;
255
+ this.locatorKey = key;
256
+ this.locator = locator ? { ...locator } : undefined;
257
+ this.ownerToken = ownerToken;
258
+ this.sessionFile = sessionFile;
259
+ this.publish(this.activity ?? undefined);
260
+ }
261
+
262
+ publish(activity: Activity | undefined, immediate = true): void {
263
+ if (this.stopped) return;
264
+ this.activity = activity ? { kind: activity.kind, text: activityText(activity.text) } : null;
265
+ this.revision++;
266
+ this.observedAt = Date.now();
267
+ this.dirty = true;
268
+ this.urgent ||= immediate;
269
+ if (immediate && this.timer) {
270
+ clearTimeout(this.timer);
271
+ this.timer = undefined;
272
+ }
273
+ this.schedule();
274
+ }
275
+
276
+ private schedule(): void {
277
+ if (this.stopped || !this.dirty || this.timer || this.writing) return;
278
+ this.timer = setTimeout(
279
+ () => {
280
+ this.timer = undefined;
281
+ void this.flush();
282
+ },
283
+ this.urgent ? 0 : Math.max(0, 250 - (Date.now() - this.lastWrite)),
284
+ );
285
+ this.timer.unref();
286
+ }
287
+
288
+ async flush(): Promise<void> {
289
+ if (this.timer) clearTimeout(this.timer);
290
+ this.timer = undefined;
291
+ if (this.writing) return this.writing;
292
+ this.writing = this.drain().finally(() => {
293
+ this.writing = undefined;
294
+ this.schedule();
295
+ });
296
+ return this.writing;
297
+ }
298
+
299
+ stop(): void {
300
+ if (this.stopped) return;
301
+ this.stopped = true;
302
+ this.dirty = false;
303
+ if (this.timer) clearTimeout(this.timer);
304
+ this.timer = undefined;
305
+ void this.closed();
306
+ }
307
+
308
+ async closed(): Promise<void> {
309
+ await this.writing;
310
+ if (this.stopped) await Promise.all([remove(this.path), remove(this.temporary)]);
311
+ }
312
+
313
+ private async drain(): Promise<void> {
314
+ const start: string | undefined = await this.start;
315
+ if (!start || this.stopped) {
316
+ this.dirty = false;
317
+ return;
318
+ }
319
+ if (this.dirty && !this.stopped) {
320
+ this.dirty = false;
321
+ this.urgent = false;
322
+ this.lastWrite = Date.now();
323
+ const snapshot: Snapshot = {
324
+ version: 1,
325
+ sessionId: this.sessionId,
326
+ launchId: this.launchId,
327
+ generation: this.generation,
328
+ pid: process.pid,
329
+ processStart: start,
330
+ revision: this.revision,
331
+ observedAt: this.observedAt,
332
+ activity: this.activity,
333
+ locator: this.locator,
334
+ ownerToken: this.ownerToken,
335
+ sessionFile: this.sessionFile,
336
+ };
337
+ await this.write(snapshot);
338
+ }
339
+ }
340
+
341
+ private async write(snapshot: Snapshot): Promise<void> {
342
+ try {
343
+ const json: string = JSON.stringify(snapshot);
344
+ if (Buffer.byteLength(json) > LIVE_MAX_BYTES) return;
345
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
346
+ await writeFile(this.temporary, json, { mode: 0o600 });
347
+ if (!this.stopped) await this.replace(this.temporary, this.path);
348
+ } catch {
349
+ // A newer queued revision owns its key and already has a pending write.
350
+ if (snapshot.revision === this.revision) this.locatorKey = undefined;
351
+ await remove(this.path);
352
+ } finally {
353
+ await remove(this.temporary);
354
+ }
355
+ }
356
+ }