@ccmsg/cli 0.1.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. package/src/version.ts +8 -0
@@ -0,0 +1,158 @@
1
+ import {
2
+ type Capability,
3
+ type InstanceId,
4
+ type SessionDumpWriteArgs,
5
+ type SessionEnvReadArgs,
6
+ type SessionEnvReadResult,
7
+ type SessionForkOriginArgs,
8
+ type SessionForkOriginResult,
9
+ type SessionKillArgs,
10
+ type SessionKillResult,
11
+ type SessionLastLiveRemoveArgs,
12
+ type SessionLastLiveRemoveResult,
13
+ type SessionRenameArgs,
14
+ type SessionRenameResult,
15
+ type SessionSearchArgs,
16
+ type Sid,
17
+ TITLE_MAX_CHARS,
18
+ type TranscriptReadArgs,
19
+ type TranscriptReadResult,
20
+ } from "@ccmsg/protocol";
21
+ import { type HandlerInput, OpError } from "../dispatch/index.ts";
22
+ import { sees, type Viewer } from "../files/index.ts";
23
+ import { readSlice, type TranscriptFiles } from "../transcript/index.ts";
24
+ import { dumpWrite } from "./dump.ts";
25
+ import { forkOrigin } from "./fork.ts";
26
+ import type { SessionProcesses } from "./processes.ts";
27
+ import { search } from "./search.ts";
28
+
29
+ /** The two capabilities of the session ops, present only where what they rest
30
+ * on is configured.
31
+ *
32
+ * `terminal` rests on a multiplexer this instance can type into, which is a
33
+ * deployment fact: a host without one has no handle a rename could reach.
34
+ * `fork` rests on the operator asking for it, because finding a seam reads
35
+ * whole sibling transcripts and a host holding large ones pays that on every
36
+ * ask. A client is told before it calls rather than refused when it does. */
37
+ export function sessionCapabilities(config: {
38
+ terminal_gateway?: string;
39
+ fork_origin: boolean;
40
+ }): Capability[] {
41
+ return [
42
+ ...(config.fork_origin ? (["fork"] as const) : []),
43
+ ...(config.terminal_gateway === undefined || config.terminal_gateway === ""
44
+ ? []
45
+ : (["terminal"] as const)),
46
+ ];
47
+ }
48
+
49
+ export interface SessionOpsDeps {
50
+ readonly self: InstanceId;
51
+ /** The one config home this instance answers for (M6). */
52
+ readonly configHome: string;
53
+ readonly stateDir: string;
54
+ readonly files: TranscriptFiles;
55
+ readonly processes: SessionProcesses;
56
+ /** Drop one entry from the list of sessions that were running when this
57
+ * instance last saw them. The sessions domain owns the list; this op only
58
+ * asks it to forget a row. */
59
+ readonly forget: (sid: Sid) => boolean;
60
+ }
61
+
62
+ /** The eight ops that observe and operate on sessions.
63
+ *
64
+ * None of them decides who may call it: dispatch has settled that from the
65
+ * attribute table. The one that narrows by role is `transcript_read`, and it
66
+ * narrows through the same `sees` the file ops narrow through — the visible
67
+ * range of a `scope: "role"` op is one rule, in one place, whatever it is
68
+ * a range over. */
69
+ export function sessionHandlers(deps: SessionOpsDeps) {
70
+ const viewer = (input: HandlerInput): Viewer => ({ role: input.role, sid: input.identity?.sid });
71
+
72
+ return {
73
+ session_kill: async (input: HandlerInput): Promise<SessionKillResult> => {
74
+ const args = input.args as unknown as SessionKillArgs;
75
+ return await deps.processes.kill(args.sid, args.force === true);
76
+ },
77
+
78
+ session_rename: async (input: HandlerInput): Promise<SessionRenameResult> => {
79
+ const args = input.args as unknown as SessionRenameArgs;
80
+ const title = validTitle(args.title);
81
+ const terminal = await deps.processes.terminal(args.sid);
82
+ // The title is typed, so the newline that submits it is a keystroke of
83
+ // its own rather than a character appended to the line: the terminal
84
+ // drains what was typed before the submit reaches it.
85
+ await deps.processes.type(terminal, [`text:/rename ${title}`, "key:Enter"]);
86
+ return { terminal_id: terminal.id, instance: deps.self, title };
87
+ },
88
+
89
+ session_env_read: async (input: HandlerInput): Promise<SessionEnvReadResult> => {
90
+ const args = input.args as unknown as SessionEnvReadArgs;
91
+ const { pid, env } = await deps.processes.environment(args.sid);
92
+ return { pid, instance: deps.self, env };
93
+ },
94
+
95
+ session_search: (input: HandlerInput) =>
96
+ search(input.args as unknown as SessionSearchArgs, {
97
+ self: deps.self,
98
+ configHome: deps.configHome,
99
+ files: deps.files,
100
+ }),
101
+
102
+ session_dump_write: (input: HandlerInput) =>
103
+ dumpWrite(input.args as unknown as SessionDumpWriteArgs, {
104
+ self: deps.self,
105
+ stateDir: deps.stateDir,
106
+ files: deps.files,
107
+ }),
108
+
109
+ session_fork_origin: (input: HandlerInput): SessionForkOriginResult => {
110
+ const args = input.args as unknown as SessionForkOriginArgs;
111
+ const origin = forkOrigin(args.sid, deps.files);
112
+ return origin === undefined ? {} : { origin };
113
+ },
114
+
115
+ session_last_live_remove: (input: HandlerInput): SessionLastLiveRemoveResult => {
116
+ const args = input.args as unknown as SessionLastLiveRemoveArgs;
117
+ // An unknown session is not an error: two clients pressing the same
118
+ // button is the ordinary case, and the caller's goal holds either way.
119
+ return { removed: deps.forget(args.sid) };
120
+ },
121
+
122
+ transcript_read: (input: HandlerInput): TranscriptReadResult => {
123
+ const args = input.args as unknown as TranscriptReadArgs;
124
+ if (!sees(args.sid, viewer(input))) {
125
+ // The role sets the visible range, not the permission (§3.2): outside
126
+ // it there is no transcript to speak of, which is the one code this op
127
+ // declares. A refusal that named the session would answer a question
128
+ // the caller was not entitled to ask.
129
+ throw new OpError("not_found", `no transcript is known for ${args.sid}`);
130
+ }
131
+ const file = deps.files.locate(args.sid, args);
132
+ return readSlice(args.sid, file, args.before, args.max_bytes);
133
+ },
134
+ };
135
+ }
136
+
137
+ /** A title fit to be typed.
138
+ *
139
+ * A control character is refused rather than stripped: the value reaches the
140
+ * terminal as keystrokes, where a newline submits a half-written command and
141
+ * the rest are control sequences — and renaming a session to something the
142
+ * caller did not write is worse than an error they can act on. */
143
+ function validTitle(raw: string): string {
144
+ const title = raw.trim();
145
+ if (title === "") throw new OpError("invalid_args", "a title is not only whitespace");
146
+ if (title.length > TITLE_MAX_CHARS) {
147
+ throw new OpError("invalid_args", `a title is at most ${TITLE_MAX_CHARS} characters`);
148
+ }
149
+ for (let at = 0; at < title.length; at++) {
150
+ // Code units are enough: every surrogate half is above this range, so no
151
+ // astral character can be read as a control character.
152
+ const code = title.charCodeAt(at);
153
+ if (code < 0x20 || code === 0x7f) {
154
+ throw new OpError("invalid_args", "a title carries no control characters");
155
+ }
156
+ }
157
+ return title;
158
+ }
@@ -0,0 +1,167 @@
1
+ import { type FSWatcher, readdirSync, readFileSync, watch } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { AgentInfo, InstanceId, Sid } from "@ccmsg/protocol";
4
+
5
+ /** The status the harness writes while a dialog is open and it is waiting for
6
+ * an answer, alongside a `waitingFor` naming what it waits on.
7
+ *
8
+ * Read out of the harness binary (2.1.263): `{status:"waiting",waitingFor:…}`.
9
+ * This is the one thing the raw status decides (§5.1 / DV-Q5) — busy and idle
10
+ * are the gateway's to say, so no other value of it is read here. */
11
+ const WAITING = "waiting";
12
+
13
+ /** How often the confirmation poll re-reads the directory.
14
+ *
15
+ * `fs.watch` is the route; this is not. macOS/Bun delivers FSEvents tens of
16
+ * seconds late under load (measured in the old daemon while many test children
17
+ * ran), and the poll exists so a change the watch is sitting on is picked up
18
+ * before a person notices it is missing (§5.1). Five seconds is the interval
19
+ * the old daemon's `claude agents` poller ran at as its only route, and this
20
+ * one replaces it as a backstop (DV-Q6), so it cannot be the slower of the
21
+ * two. */
22
+ export const CONFIRM_POLL_MS = 5_000;
23
+
24
+ const STATE_FILE = /^\d+\.json$/;
25
+
26
+ /** The sessions the harness itself reports, read from one config home.
27
+ *
28
+ * The directory is the whole input: it says which sessions exist and which is
29
+ * waiting on a dialog (§5.1). Only the config home this instance was given is
30
+ * ever opened (M6) — the path is handed in, and nothing here searches for
31
+ * another one.
32
+ *
33
+ * Two things live here, and §6.3 separates them. Reading the directory answers
34
+ * a question, and is done whenever one is asked. Watching it says the answer
35
+ * may have changed, which is only worth knowing while somebody is subscribed —
36
+ * so the watch is what the subscription drives, and no answer waits on it. */
37
+ export class HarnessSessions {
38
+ #watcher: FSWatcher | undefined;
39
+ #timer: ReturnType<typeof setInterval> | undefined;
40
+
41
+ constructor(
42
+ private readonly dir: string,
43
+ private readonly instance: InstanceId,
44
+ private readonly onChange: () => void,
45
+ private readonly pollMs: number = CONFIRM_POLL_MS,
46
+ ) {}
47
+
48
+ get running(): boolean {
49
+ return this.#watcher !== undefined || this.#timer !== undefined;
50
+ }
51
+
52
+ /** Begins watching. Called when the first subscriber arrives and not before
53
+ * (§6.3 / §8.3: no upstream is read until somebody is listening). */
54
+ start(): void {
55
+ if (this.running) return;
56
+ try {
57
+ this.#watcher = watch(this.dir, this.onChange);
58
+ } catch {
59
+ // The directory does not exist yet — a config home whose harness has not
60
+ // run. The poll below both covers the wait and picks it up when it
61
+ // appears, so this is not a failure to start.
62
+ this.#watcher = undefined;
63
+ }
64
+ this.#timer = setInterval(this.onChange, this.pollMs);
65
+ this.onChange();
66
+ }
67
+
68
+ stop(): void {
69
+ this.#watcher?.close();
70
+ this.#watcher = undefined;
71
+ if (this.#timer !== undefined) clearInterval(this.#timer);
72
+ this.#timer = undefined;
73
+ }
74
+
75
+ /** The directory as it is at this instant.
76
+ *
77
+ * Every answer comes from here rather than from anything the watch left
78
+ * behind. Which sessions exist is an input to the classification (§5.1), and
79
+ * classifying happens inside `message_send`'s decision and inside the
80
+ * recompute that writes `last_live` — neither of which can hand back a
81
+ * promise without changing what it means, and neither of which may depend on
82
+ * somebody being subscribed. The ops that signal a session's process read it
83
+ * here too: a pid from a poll that has not run is a number belonging to
84
+ * nobody.
85
+ *
86
+ * Read in place because the directory is a handful of small files of this
87
+ * uid's own config home (M6) — a syscall or two per session, not a wait. */
88
+ scan(): ReadonlyMap<Sid, AgentInfo> {
89
+ const rows = new Map<Sid, AgentInfo>();
90
+ let names: string[];
91
+ try {
92
+ names = readdirSync(this.dir);
93
+ } catch {
94
+ return rows;
95
+ }
96
+ for (const name of names) {
97
+ if (!STATE_FILE.test(name)) continue;
98
+ let document: unknown;
99
+ try {
100
+ document = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
101
+ } catch {
102
+ continue;
103
+ }
104
+ const row = toRow(document, this.dir, this.instance);
105
+ if (row !== undefined) rows.set(row.sid, row);
106
+ }
107
+ return rows;
108
+ }
109
+ }
110
+
111
+ /** Whether the harness says this session is waiting on a dialog. */
112
+ export function isWaiting(row: AgentInfo): boolean {
113
+ return row.status === WAITING;
114
+ }
115
+
116
+ /** The conversion of one upstream document into the contract's spelling
117
+ * (§3.5): renamed to snake_case, instants in Unix ms, and nothing carried over
118
+ * that the contract does not name.
119
+ *
120
+ * A row whose process is gone is dropped: the file outlives a session that did
121
+ * not clean up after itself, and "the session exists" is what this input is
122
+ * for. */
123
+ function toRow(document: unknown, configDir: string, instance: InstanceId): AgentInfo | undefined {
124
+ if (typeof document !== "object" || document === null) return undefined;
125
+ const raw = document as Record<string, unknown>;
126
+ const sid = text(raw["sessionId"]);
127
+ const pid = raw["pid"];
128
+ const cwd = text(raw["cwd"]);
129
+ const kind = text(raw["kind"]);
130
+ const startedAt = raw["startedAt"];
131
+ if (sid === undefined || cwd === undefined || kind === undefined) return undefined;
132
+ if (typeof pid !== "number" || typeof startedAt !== "number") return undefined;
133
+ if (!alive(pid)) return undefined;
134
+ return {
135
+ sid,
136
+ instance,
137
+ pid,
138
+ cwd,
139
+ kind,
140
+ started_at: startedAt,
141
+ config_dir: configDir,
142
+ ...optional("name", text(raw["name"])),
143
+ ...optional("status", text(raw["status"])),
144
+ ...optional("waiting_for", text(raw["waitingFor"])),
145
+ ...optional("state", text(raw["state"])),
146
+ ...optional("background_id", text(raw["backgroundId"])),
147
+ };
148
+ }
149
+
150
+ function alive(pid: number): boolean {
151
+ try {
152
+ process.kill(pid, 0);
153
+ return true;
154
+ } catch {
155
+ // EPERM would mean alive but ours to signal — impossible here, since the
156
+ // daemon and the sessions of its config home run as one uid (§2 A4).
157
+ return false;
158
+ }
159
+ }
160
+
161
+ function text(value: unknown): string | undefined {
162
+ return typeof value === "string" && value.length > 0 ? value : undefined;
163
+ }
164
+
165
+ function optional<K extends string, V>(key: K, value: V | undefined): Record<K, V> | object {
166
+ return value === undefined ? {} : { [key]: value };
167
+ }
@@ -0,0 +1,26 @@
1
+ export * from "./classify.ts";
2
+ export { DUMPS, dumpWrite } from "./dump.ts";
3
+ export { forkOrigin } from "./fork.ts";
4
+ export * from "./harness.ts";
5
+ export { sessionCapabilities, sessionHandlers, type SessionOpsDeps } from "./handlers.ts";
6
+ export * from "./last-live.ts";
7
+ export {
8
+ elapsedSeconds,
9
+ GRACE_MS,
10
+ hostProcessDeps,
11
+ hostTerminalReader,
12
+ LIVENESS_POLL_MS,
13
+ parseEnvironment,
14
+ type ProcessDeps,
15
+ SECOND_SIGNAL_AFTER_MS,
16
+ SessionProcesses,
17
+ STARTED_AFTER_TOLERANCE_MS,
18
+ STARTED_BEFORE_TOLERANCE_MS,
19
+ type Terminal,
20
+ terminalOf,
21
+ } from "./processes.ts";
22
+ export * from "./registry.ts";
23
+ export { search, type SearchDeps } from "./search.ts";
24
+ export * from "./status.ts";
25
+ export { TerminalCache, type TerminalReader } from "./terminals.ts";
26
+ export { workspaceFolders } from "./workspace.ts";
@@ -0,0 +1,111 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import {
4
+ LAST_LIVE_RETENTION_MS,
5
+ type LastLiveSession,
6
+ type Sid,
7
+ type Timestamp,
8
+ } from "@ccmsg/protocol";
9
+
10
+ export const LAST_LIVE_FILE = "last-live.json";
11
+
12
+ /** What is stored per session: the contract's entry, minus the one field that
13
+ * is derived rather than observed.
14
+ *
15
+ * `state` is left out on purpose (M4). It follows from `stopped_at` and from
16
+ * whether the session is live again, both of which are known when the list is
17
+ * read, so storing it would be storing a conclusion that can go stale on disk.
18
+ * `pinned` is left out because no pin is held anywhere yet; when one is, it
19
+ * belongs to the session rather than to this list. */
20
+ export type StoredEntry = Omit<LastLiveSession, "state" | "pinned">;
21
+
22
+ interface Document {
23
+ version: number;
24
+ sessions: StoredEntry[];
25
+ }
26
+
27
+ const VERSION = 1;
28
+
29
+ /** The sessions that were running when this instance last saw them.
30
+ *
31
+ * One of the three things written to disk (§3.6): losing it loses the Paused
32
+ * and Disappeared rows of the list entirely, and nothing else on the host
33
+ * remembers that a session used to be here. Only observations are stored — the
34
+ * classification is derived from them at read time, never written (M4). */
35
+ export class LastLiveStore {
36
+ #entries = new Map<Sid, StoredEntry>();
37
+
38
+ constructor(private readonly file: string) {}
39
+
40
+ /** Read at startup (§8.3 step 4), before anything can ask for the list. A
41
+ * file that is missing or unreadable starts an empty list: the daemon has no
42
+ * way to recover it and refusing to start would cost more than the rows. */
43
+ load(now: Timestamp = Date.now()): void {
44
+ let document: unknown;
45
+ try {
46
+ document = JSON.parse(readFileSync(this.file, "utf8"));
47
+ } catch {
48
+ return;
49
+ }
50
+ const sessions = (document as Document | null)?.sessions;
51
+ if (!Array.isArray(sessions)) return;
52
+ for (const entry of sessions as StoredEntry[]) {
53
+ if (typeof entry?.sid === "string") this.#entries.set(entry.sid, entry);
54
+ }
55
+ this.#prune(now);
56
+ }
57
+
58
+ /** Every entry still within the retention window. */
59
+ entries(now: Timestamp = Date.now()): StoredEntry[] {
60
+ if (this.#prune(now)) this.#save();
61
+ return [...this.#entries.values()];
62
+ }
63
+
64
+ get(sid: Sid): StoredEntry | undefined {
65
+ return this.#entries.get(sid);
66
+ }
67
+
68
+ /** Note a session as no longer live. A `stopped_at` already recorded for it
69
+ * survives, since the session being gone is what that stop led to; the entry
70
+ * carries one when the session declared it was going, which is what makes it
71
+ * Paused rather than Disappeared (§5.2). */
72
+ record(entry: StoredEntry): void {
73
+ const stopped = this.#entries.get(entry.sid)?.stopped_at ?? entry.stopped_at;
74
+ this.#entries.set(entry.sid, stopped === undefined ? entry : { ...entry, stopped_at: stopped });
75
+ this.#save();
76
+ }
77
+
78
+ /** Drop one entry: `session_last_live_remove`, and a session registering
79
+ * again, which is what moves it back to the connected list. */
80
+ remove(sid: Sid): boolean {
81
+ if (!this.#entries.delete(sid)) return false;
82
+ this.#save();
83
+ return true;
84
+ }
85
+
86
+ #prune(now: Timestamp): boolean {
87
+ let dropped = false;
88
+ for (const [sid, entry] of this.#entries) {
89
+ if (now - entry.last_seen_at <= LAST_LIVE_RETENTION_MS) continue;
90
+ this.#entries.delete(sid);
91
+ dropped = true;
92
+ }
93
+ return dropped;
94
+ }
95
+
96
+ /** Written whole through a temporary file, so a daemon killed mid-write
97
+ * leaves the previous list rather than half of this one. */
98
+ #save(): void {
99
+ const document: Document = { version: VERSION, sessions: [...this.#entries.values()] };
100
+ const temporary = `${this.file}.${process.pid}.tmp`;
101
+ mkdirSync(dirname(this.file), { recursive: true });
102
+ writeFileSync(temporary, `${JSON.stringify(document)}\n`);
103
+ renameSync(temporary, this.file);
104
+ }
105
+ }
106
+
107
+ /** Where the list lives for an instance whose state directory is `stateDir`
108
+ * (§8.1: every per-instance path is derived from its config home). */
109
+ export function lastLivePath(stateDir: string): string {
110
+ return join(stateDir, LAST_LIVE_FILE);
111
+ }