@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.
- package/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- 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
|
+
}
|