@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/src/pi/owner.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * What a session does for the task it drives, on its own clock. Every few
3
- * seconds a session that owns a task (in the terminal or headless, started
4
- * from another window's lobby) passes on what other sessions left for its
5
- * oracle, plan comments and inbox messages, and in auto mode keeps the oracle
6
- * going whenever its turn ends before the task is done. Subagents never own
7
- * tasks, so none of this runs in them.
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?.();
@@ -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
+ }
@@ -1,17 +1,21 @@
1
1
  /**
2
- * The task inbox: messages for a task's oracle written from any pi session
3
- * (the lobby of another window talking to a task it does not drive). Like plan
4
- * comments they live beside the task as an append-only JSON-lines log, never
5
- * inside state.json, and the session that owns the task delivers them to its
6
- * Master and records the delivery.
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
- taskId: string;
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
- /** The task's messages, oldest first, with whether each reached the oracle. */
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(inboxPath(root, configDir, taskId))) {
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
- /** Leave a message for the task's oracle; returns it. */
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(inboxPath(root, configDir, taskId), { kind: "message", id, text: body, at: now.toISOString(), ...(by ? { by } : {}) });
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
- for (const id of ids) append(inboxPath(root, configDir, taskId), { kind: "delivered", id, at: now.toISOString() });
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
+ }