@a-t-h-i/bot-lobby 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -17
- package/package.json +1 -1
- package/src/lobby/keys.ts +1 -1
- package/src/lobby/runtime.ts +124 -4
- package/src/lobby/session-files.ts +12 -0
- package/src/lobby/sessions.ts +28 -2
- package/src/lobby/tabs/home.ts +54 -26
- package/src/lobby/tabs/tasks.ts +30 -14
- package/src/lobby/view.ts +307 -38
- package/src/pi/commands.ts +20 -2
- package/src/pi/owner.ts +57 -8
- package/src/schemas/task.ts +2 -0
- package/src/state/archive.ts +99 -0
- package/src/state/inbox.ts +49 -15
- package/src/state/presence.ts +99 -0
package/src/pi/owner.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a session does
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* oracle
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* What a session does on its own clock. Every few seconds a session (in the
|
|
3
|
+
* terminal or headless, started from another window's lobby) refreshes its
|
|
4
|
+
* heartbeat so other windows see it running, passes on what other sessions
|
|
5
|
+
* left for its oracle — plan comments, messages for its task or for the
|
|
6
|
+
* session itself — and in auto mode keeps the oracle going whenever its turn
|
|
7
|
+
* ends before the task is done. Subagents never own tasks or show up as
|
|
8
|
+
* sessions, so none of this runs in them.
|
|
8
9
|
*/
|
|
9
10
|
import type { KeyId } from "@earendil-works/pi-tui";
|
|
10
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -12,7 +13,8 @@ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
|
12
13
|
import { activeTask, loadTask } from "../state/persistence.ts";
|
|
13
14
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
14
15
|
import { commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
|
|
15
|
-
import { inboxMessage, markInboxDelivered, readInbox } from "../state/inbox.ts";
|
|
16
|
+
import { inboxMessage, markInboxDelivered, markSessionInboxDelivered, readInbox, readSessionInbox } from "../state/inbox.ts";
|
|
17
|
+
import { removePresence, writePresence } from "../state/presence.ts";
|
|
16
18
|
import { isAutoMode, setAutoMode } from "../state/auto.ts";
|
|
17
19
|
import { isSubagentProcess } from "./quiet.ts";
|
|
18
20
|
import { isMinimized } from "./ui.ts";
|
|
@@ -26,6 +28,7 @@ export const AUTO_KEY = "alt+g";
|
|
|
26
28
|
|
|
27
29
|
export type OwnerEvent =
|
|
28
30
|
| { kind: "comments" | "inbox"; taskId: string; count: number }
|
|
31
|
+
| { kind: "messages"; count: number }
|
|
29
32
|
| { kind: "auto"; taskId: string; on: boolean }
|
|
30
33
|
| { kind: "nudge"; taskId: string }
|
|
31
34
|
| { kind: "stalled"; taskId: string };
|
|
@@ -33,6 +36,8 @@ export type OwnerEvent =
|
|
|
33
36
|
interface Owner {
|
|
34
37
|
pi: ExtensionAPI;
|
|
35
38
|
ctx: ExtensionContext;
|
|
39
|
+
/** The session this clock runs for, kept so its heartbeat can be removed after its context goes stale. */
|
|
40
|
+
sessionId: string;
|
|
36
41
|
root: string;
|
|
37
42
|
configDir: string;
|
|
38
43
|
timer?: ReturnType<typeof setInterval>;
|
|
@@ -84,6 +89,40 @@ export function deliverInbox(): number {
|
|
|
84
89
|
return fresh.length;
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/** Pass messages other sessions left for this session itself (it may have no task) to its Master. */
|
|
93
|
+
export function deliverSessionInbox(): number {
|
|
94
|
+
const state = owner;
|
|
95
|
+
if (!state) return 0;
|
|
96
|
+
const sessionId = state.ctx.sessionManager.getSessionId();
|
|
97
|
+
const fresh = readSessionInbox(state.root, state.configDir, sessionId).filter((message) => !message.delivered);
|
|
98
|
+
if (fresh.length === 0) return 0;
|
|
99
|
+
state.pi.sendUserMessage(inboxMessage(fresh), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
|
|
100
|
+
markSessionInboxDelivered(state.root, state.configDir, sessionId, fresh.map((message) => message.id));
|
|
101
|
+
listener?.({ kind: "messages", count: fresh.length });
|
|
102
|
+
return fresh.length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Tell other windows this session is running: its name, file, task and process. */
|
|
106
|
+
export function heartbeat(): void {
|
|
107
|
+
const state = owner;
|
|
108
|
+
if (!state) return;
|
|
109
|
+
const name = state.pi.getSessionName?.();
|
|
110
|
+
const sessionFile = state.ctx.sessionManager.getSessionFile?.();
|
|
111
|
+
const task = ownTask(state);
|
|
112
|
+
try {
|
|
113
|
+
writePresence(state.root, state.configDir, {
|
|
114
|
+
sessionId: state.ctx.sessionManager.getSessionId(),
|
|
115
|
+
pid: process.pid,
|
|
116
|
+
mode: state.ctx.mode ?? "tui",
|
|
117
|
+
...(name ? { name } : {}),
|
|
118
|
+
...(sessionFile ? { sessionFile } : {}),
|
|
119
|
+
...(live(task) ? { taskId: task.id } : {}),
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
// A read-only project only means other windows do not see this one.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
87
126
|
/** What auto mode remembers between nudges: the task as it was, and how many nudges changed nothing. */
|
|
88
127
|
export interface AutoTrack {
|
|
89
128
|
fingerprint: string;
|
|
@@ -172,13 +211,22 @@ export function toggleOwnAuto(): string {
|
|
|
172
211
|
|
|
173
212
|
/** One tick of the owner's clock. */
|
|
174
213
|
export function ownerTick(): void {
|
|
214
|
+
heartbeat();
|
|
175
215
|
deliverComments();
|
|
176
216
|
deliverInbox();
|
|
217
|
+
deliverSessionInbox();
|
|
177
218
|
driveAuto();
|
|
178
219
|
}
|
|
179
220
|
|
|
180
221
|
function stop(): void {
|
|
181
222
|
if (owner?.timer) clearInterval(owner.timer);
|
|
223
|
+
if (owner) {
|
|
224
|
+
try {
|
|
225
|
+
removePresence(owner.root, owner.configDir, owner.sessionId);
|
|
226
|
+
} catch {
|
|
227
|
+
// The heartbeat goes stale on its own.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
182
230
|
owner = undefined;
|
|
183
231
|
}
|
|
184
232
|
|
|
@@ -186,7 +234,8 @@ function stop(): void {
|
|
|
186
234
|
export function startOwner(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, pollMs = OWNER_POLL_MS): void {
|
|
187
235
|
stop();
|
|
188
236
|
if (isSubagentProcess()) return;
|
|
189
|
-
owner = { pi, ctx, root: detectProjectRoot(ctx.cwd, configDir), configDir };
|
|
237
|
+
owner = { pi, ctx, sessionId: ctx.sessionManager.getSessionId(), root: detectProjectRoot(ctx.cwd, configDir), configDir };
|
|
238
|
+
heartbeat();
|
|
190
239
|
if (pollMs > 0) {
|
|
191
240
|
owner.timer = setInterval(() => ownerTick(), pollMs);
|
|
192
241
|
owner.timer.unref?.();
|
package/src/schemas/task.ts
CHANGED
|
@@ -121,6 +121,8 @@ export interface Task {
|
|
|
121
121
|
ownerSessionId?: string;
|
|
122
122
|
/** The planned task (PLAN-…) whose plan the user agreed in the planning panel: its proposal needs no approval. */
|
|
123
123
|
approvedPlan?: string;
|
|
124
|
+
/** When the task was archived from the lobby (it then lives under archive/tasks, out of every list). */
|
|
125
|
+
archivedAt?: string;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
export function createTask(
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archiving and deleting tasks from the lobby. An archived task moves, whole,
|
|
3
|
+
* out of the task tree to `archive/tasks/<id>` beside the knowledge archive:
|
|
4
|
+
* no list, widget or session sees it any more, and it can be restored as it
|
|
5
|
+
* was. Deleting removes a task's folder for good. A task still under way is
|
|
6
|
+
* abandoned before it is archived, so it never comes back half-driven.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
11
|
+
import { taskDir, tasksRoot } from "../knowledge/paths.ts";
|
|
12
|
+
import { transition } from "./task-state.ts";
|
|
13
|
+
import { dataRoot, readDataRoots } from "./project.ts";
|
|
14
|
+
|
|
15
|
+
export function archiveRoot(root: string, configDir: string): string {
|
|
16
|
+
return join(dataRoot(root, configDir), "archive", "tasks");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Task ids become folder names: only ever one plain path segment. */
|
|
20
|
+
function checkId(taskId: string): string {
|
|
21
|
+
if (!/^[\w.-]+$/.test(taskId) || taskId === "." || taskId === "..") throw new Error(`not a task id: ${taskId}`);
|
|
22
|
+
return taskId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readAt(dir: string): Task | undefined {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(join(dir, "state.json"), "utf8")) as Task;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeAt(dir: string, task: Task): void {
|
|
34
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify(task, null, 2), "utf8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Where a task on the list lives: the newest tree that has it (bot-lobby, then the pre-rename ones). */
|
|
38
|
+
function liveDir(root: string, configDir: string, taskId: string): string | undefined {
|
|
39
|
+
return readDataRoots(root, configDir).map((dr) => taskDir(dr, taskId)).find((dir) => existsSync(join(dir, "state.json")));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function archivedDir(root: string, configDir: string, taskId: string): string {
|
|
43
|
+
return join(archiveRoot(root, configDir), checkId(taskId));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Archived tasks, most recently archived first. */
|
|
47
|
+
export function listArchivedTasks(root: string, configDir: string): Task[] {
|
|
48
|
+
const dir = archiveRoot(root, configDir);
|
|
49
|
+
if (!existsSync(dir)) return [];
|
|
50
|
+
const tasks: Task[] = [];
|
|
51
|
+
for (const entry of readdirSync(dir)) {
|
|
52
|
+
const task = readAt(join(dir, entry));
|
|
53
|
+
if (task) tasks.push(task);
|
|
54
|
+
}
|
|
55
|
+
return tasks.sort((a, b) => (b.archivedAt ?? b.updatedAt).localeCompare(a.archivedAt ?? a.updatedAt));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Move a task out of every list into the archive; one still under way is
|
|
60
|
+
* abandoned first (and says so in its decisions). Returns the archived task.
|
|
61
|
+
*/
|
|
62
|
+
export function archiveTask(root: string, configDir: string, taskId: string, now = new Date()): Task {
|
|
63
|
+
const from = liveDir(root, configDir, checkId(taskId));
|
|
64
|
+
const task = from ? readAt(from) : undefined;
|
|
65
|
+
if (!from || !task) throw new Error(`no task ${taskId}`);
|
|
66
|
+
const to = archivedDir(root, configDir, taskId);
|
|
67
|
+
if (existsSync(to)) throw new Error(`${taskId} is already in the archive`);
|
|
68
|
+
const stamp = now.toISOString();
|
|
69
|
+
if (!TERMINAL_STATES.includes(task.state)) {
|
|
70
|
+
transition(task, "abandoned", stamp);
|
|
71
|
+
task.decisions.push({ domain: "master", text: "Abandoned when it was archived from the lobby.", createdAt: stamp });
|
|
72
|
+
}
|
|
73
|
+
task.archivedAt = stamp;
|
|
74
|
+
writeAt(from, task);
|
|
75
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
76
|
+
renameSync(from, to);
|
|
77
|
+
return task;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Bring an archived task back to the lists, as it was when archived. */
|
|
81
|
+
export function restoreTask(root: string, configDir: string, taskId: string): Task {
|
|
82
|
+
const from = archivedDir(root, configDir, taskId);
|
|
83
|
+
const task = readAt(from);
|
|
84
|
+
if (!task) throw new Error(`${taskId} is not in the archive`);
|
|
85
|
+
const to = taskDir(dataRoot(root, configDir), taskId);
|
|
86
|
+
if (existsSync(to)) throw new Error(`a task ${taskId} is already on the list`);
|
|
87
|
+
delete task.archivedAt;
|
|
88
|
+
writeAt(from, task);
|
|
89
|
+
mkdirSync(tasksRoot(dataRoot(root, configDir)), { recursive: true });
|
|
90
|
+
renameSync(from, to);
|
|
91
|
+
return task;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove a task for good: its folder, from the list or from the archive. */
|
|
95
|
+
export function deleteTask(root: string, configDir: string, taskId: string, where: "list" | "archive" = "list"): void {
|
|
96
|
+
const dir = where === "archive" ? archivedDir(root, configDir, taskId) : liveDir(root, configDir, checkId(taskId));
|
|
97
|
+
if (!dir || !existsSync(dir)) throw new Error(where === "archive" ? `${taskId} is not in the archive` : `no task ${taskId}`);
|
|
98
|
+
rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
package/src/state/inbox.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (the lobby of another window talking to a
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Inboxes: messages for another session's oracle written from any pi session
|
|
3
|
+
* (the lobby of another window talking to a session it does not drive). A
|
|
4
|
+
* task's inbox lives beside the task, so a message waits there until some
|
|
5
|
+
* session drives the task; a session's inbox lives beside its heartbeat, for
|
|
6
|
+
* a running session with no task. Like plan comments they are append-only
|
|
7
|
+
* JSON-lines logs, never inside state.json, and the session they are for
|
|
8
|
+
* delivers them to its Master and records the delivery.
|
|
7
9
|
*/
|
|
8
10
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
11
|
import { dirname, join } from "node:path";
|
|
10
12
|
import { taskDirFor } from "./persistence.ts";
|
|
13
|
+
import { presenceDir } from "./presence.ts";
|
|
11
14
|
|
|
12
15
|
export interface InboxMessage {
|
|
13
16
|
id: string;
|
|
14
|
-
|
|
17
|
+
/** The task it was left for; absent for a session's own inbox. */
|
|
18
|
+
taskId?: string;
|
|
15
19
|
text: string;
|
|
16
20
|
createdAt: string;
|
|
17
21
|
/** The pi session that wrote it. */
|
|
@@ -30,6 +34,10 @@ export function inboxPath(root: string, configDir: string, taskId: string): stri
|
|
|
30
34
|
return join(taskDirFor(root, configDir, taskId), "inbox.jsonl");
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
export function sessionInboxPath(root: string, configDir: string, sessionId: string): string {
|
|
38
|
+
return join(presenceDir(root, configDir), `${sessionId.replace(/[^\w.-]/g, "_")}.inbox.jsonl`);
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
function append(path: string, event: InboxEvent): void {
|
|
34
42
|
mkdirSync(dirname(path), { recursive: true });
|
|
35
43
|
appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
|
|
@@ -56,12 +64,11 @@ function readEvents(path: string): InboxEvent[] {
|
|
|
56
64
|
return events;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
export function readInbox(root: string, configDir: string, taskId: string): InboxMessage[] {
|
|
67
|
+
function readAt(path: string, taskId?: string): InboxMessage[] {
|
|
61
68
|
const byId = new Map<string, InboxMessage>();
|
|
62
|
-
for (const event of readEvents(
|
|
69
|
+
for (const event of readEvents(path)) {
|
|
63
70
|
if (event.kind === "message" && !byId.has(event.id)) {
|
|
64
|
-
byId.set(event.id, { id: event.id, taskId, text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), delivered: false });
|
|
71
|
+
byId.set(event.id, { id: event.id, ...(taskId ? { taskId } : {}), text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), delivered: false });
|
|
65
72
|
} else if (event.kind === "delivered") {
|
|
66
73
|
const message = byId.get(event.id);
|
|
67
74
|
if (message) message.delivered = true;
|
|
@@ -70,17 +77,44 @@ export function readInbox(root: string, configDir: string, taskId: string): Inbo
|
|
|
70
77
|
return [...byId.values()];
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
export function sendToInbox(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): InboxMessage {
|
|
80
|
+
function sendAt(path: string, text: string, by: string | undefined, now: Date, taskId?: string): InboxMessage {
|
|
75
81
|
const body = text.trim().slice(0, MAX_INBOX_CHARS);
|
|
76
82
|
if (!body) throw new Error("a message needs some text");
|
|
77
83
|
const id = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
78
|
-
append(
|
|
79
|
-
return { id, taskId, text: body, createdAt: now.toISOString(), ...(by ? { by } : {}), delivered: false };
|
|
84
|
+
append(path, { kind: "message", id, text: body, at: now.toISOString(), ...(by ? { by } : {}) });
|
|
85
|
+
return { id, ...(taskId ? { taskId } : {}), text: body, createdAt: now.toISOString(), ...(by ? { by } : {}), delivered: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function markAt(path: string, ids: readonly string[], now: Date): void {
|
|
89
|
+
for (const id of ids) append(path, { kind: "delivered", id, at: now.toISOString() });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The task's messages, oldest first, with whether each reached the oracle. */
|
|
93
|
+
export function readInbox(root: string, configDir: string, taskId: string): InboxMessage[] {
|
|
94
|
+
return readAt(inboxPath(root, configDir, taskId), taskId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Leave a message for the task's oracle; returns it. */
|
|
98
|
+
export function sendToInbox(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): InboxMessage {
|
|
99
|
+
return sendAt(inboxPath(root, configDir, taskId), text, by, now, taskId);
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
export function markInboxDelivered(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
|
|
83
|
-
|
|
103
|
+
markAt(inboxPath(root, configDir, taskId), ids, now);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A running session's own messages (it has no task to hold them), oldest first. */
|
|
107
|
+
export function readSessionInbox(root: string, configDir: string, sessionId: string): InboxMessage[] {
|
|
108
|
+
return readAt(sessionInboxPath(root, configDir, sessionId));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Leave a message for a running session's oracle; returns it. */
|
|
112
|
+
export function sendToSession(root: string, configDir: string, sessionId: string, text: string, by?: string, now = new Date()): InboxMessage {
|
|
113
|
+
return sendAt(sessionInboxPath(root, configDir, sessionId), text, by, now);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function markSessionInboxDelivered(root: string, configDir: string, sessionId: string, ids: readonly string[], now = new Date()): void {
|
|
117
|
+
markAt(sessionInboxPath(root, configDir, sessionId), ids, now);
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
/** The message the owning session sends its Master for undelivered inbox messages. */
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pi sessions are running in this project right now. Every interactive
|
|
3
|
+
* or headless session with bot-lobby loaded (never a subagent) keeps a small
|
|
4
|
+
* heartbeat file beside the tasks, refreshed on its owner clock and removed
|
|
5
|
+
* when it ends; the lobby reads them to list the live sessions it can view,
|
|
6
|
+
* message or switch to. A heartbeat older than a few beats, or from a process
|
|
7
|
+
* that is gone, does not count.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { dataRoot } from "./project.ts";
|
|
12
|
+
|
|
13
|
+
export interface Presence {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
pid: number;
|
|
16
|
+
/** The pi session's name (a task's title once it starts one). */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** The saved session file, so another window can switch to it once it ends. */
|
|
19
|
+
sessionFile?: string;
|
|
20
|
+
/** The task it drives, if any. */
|
|
21
|
+
taskId?: string;
|
|
22
|
+
/** `tui` for a terminal, `rpc` for a session another window started in the background. */
|
|
23
|
+
mode: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A heartbeat older than this means the session is gone (its owner clock ticks every few seconds). */
|
|
28
|
+
export const PRESENCE_TTL_MS = 15_000;
|
|
29
|
+
/** A heartbeat this old is removed even if its process still runs (it switched sessions without saying so). */
|
|
30
|
+
const FORGET_MS = 10 * 60_000;
|
|
31
|
+
|
|
32
|
+
export function presenceDir(root: string, configDir: string): string {
|
|
33
|
+
return join(dataRoot(root, configDir), "sessions");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Session ids become file names: keep them to safe characters. */
|
|
37
|
+
function safeId(sessionId: string): string | undefined {
|
|
38
|
+
return /^[\w.-]+$/.test(sessionId) ? sessionId : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function presencePath(root: string, configDir: string, sessionId: string): string | undefined {
|
|
42
|
+
const id = safeId(sessionId);
|
|
43
|
+
return id ? join(presenceDir(root, configDir), `${id}.json`) : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Record (or refresh) this session's heartbeat; atomic, so readers never see half a file. */
|
|
47
|
+
export function writePresence(root: string, configDir: string, presence: Omit<Presence, "updatedAt">, now = new Date()): void {
|
|
48
|
+
const path = presencePath(root, configDir, presence.sessionId);
|
|
49
|
+
if (!path) return;
|
|
50
|
+
mkdirSync(presenceDir(root, configDir), { recursive: true });
|
|
51
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
52
|
+
writeFileSync(tmp, JSON.stringify({ ...presence, updatedAt: now.toISOString() }), "utf8");
|
|
53
|
+
renameSync(tmp, path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function removePresence(root: string, configDir: string, sessionId: string): void {
|
|
57
|
+
const path = presencePath(root, configDir, sessionId);
|
|
58
|
+
if (path) rmSync(path, { force: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether a process is running: signal 0 checks without touching it (EPERM still means it exists). */
|
|
62
|
+
export function processAlive(pid: number): boolean {
|
|
63
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The sessions running in this project, newest heartbeat first. Heartbeats
|
|
74
|
+
* of processes that are gone are removed; merely late ones are skipped.
|
|
75
|
+
*/
|
|
76
|
+
export function livePresence(root: string, configDir: string, now = Date.now(), alive: (pid: number) => boolean = processAlive): Presence[] {
|
|
77
|
+
const dir = presenceDir(root, configDir);
|
|
78
|
+
if (!existsSync(dir)) return [];
|
|
79
|
+
const live: Presence[] = [];
|
|
80
|
+
for (const file of readdirSync(dir)) {
|
|
81
|
+
if (!file.endsWith(".json") || file.endsWith(".tmp")) continue;
|
|
82
|
+
let presence: Presence;
|
|
83
|
+
try {
|
|
84
|
+
presence = JSON.parse(readFileSync(join(dir, file), "utf8")) as Presence;
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (typeof presence?.sessionId !== "string" || typeof presence.pid !== "number") continue;
|
|
89
|
+
const age = now - Date.parse(presence.updatedAt);
|
|
90
|
+
// Gone, or so long silent that its process has moved on to another session: clear it away.
|
|
91
|
+
if (!alive(presence.pid) || !(age < FORGET_MS)) {
|
|
92
|
+
rmSync(join(dir, file), { force: true });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (age > PRESENCE_TTL_MS) continue;
|
|
96
|
+
live.push(presence);
|
|
97
|
+
}
|
|
98
|
+
return live.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
99
|
+
}
|