@monster-orange/den 0.2.8 → 0.4.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 (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +12 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,667 @@
1
+ /**
2
+ * The session host (AG-3a, AG spec §3): one small detached process per
3
+ * distribution that owns every PTY and serves them on a unix socket in the
4
+ * state dir (`<stateDir>/sessions.sock`, mode 0600). The CLI and, later,
5
+ * the web server are its clients, so restarting the web server leaves every
6
+ * session running. A reboot (or stopping the host) ends them; their records
7
+ * and scrollback stay, listed as exited, until forgotten.
8
+ *
9
+ * Starting is idempotent: a host that finds another one answering on the
10
+ * socket stands down ("already-running"). A socket nobody answers on is
11
+ * stale (its host died) and is replaced. A lock file held only while
12
+ * starting keeps two hosts from both replacing it at once.
13
+ *
14
+ * The host never logs or stores a session's env or argv: a record holds
15
+ * its name, cwd, pid, times, exit code and whether it was ever prompted
16
+ * (input with a line end written to it), never the input itself.
17
+ */
18
+ import { randomBytes } from "crypto";
19
+ import * as fs from "fs";
20
+ import * as net from "net";
21
+ import * as path from "path";
22
+ import { DEFAULT_SCROLLBACK_BYTES, ScrollbackLog } from "./ring-buffer.js";
23
+ import { MAX_SOCKET_PATH, sessionPaths, type SessionPaths } from "./paths.js";
24
+ import {
25
+ frame,
26
+ LineReader,
27
+ PROTOCOL_VERSION,
28
+ type HostErrorCode,
29
+ type Request,
30
+ } from "./protocol.js";
31
+ import type { PtyLoad, PtyProcess } from "./pty.js";
32
+ import { SESSION_SIGNALS, type SessionInfo } from "./types.js";
33
+
34
+ export const BACKEND_ID = "den-pty";
35
+
36
+ /** A client this far behind on output is dropped (it can reattach). */
37
+ const MAX_BACKLOG_BYTES = 8 * 1024 * 1024;
38
+ /** Output can trail a PTY's exit; the exit is reported after this. */
39
+ const EXIT_SETTLE_MS = 50;
40
+ const LOCK_WAIT_MS = 5000;
41
+
42
+ export interface SessionHostOptions {
43
+ stateDir: string;
44
+ /** The PTY module, asked at each start (it may be installed later). */
45
+ pty: () => PtyLoad;
46
+ scrollbackBytes?: number;
47
+ /** Scrollback log flush delay (0: at exit and close only). */
48
+ flushMs?: number;
49
+ log?: (line: string) => void;
50
+ /** Called after a `shutdown` request has closed the host. */
51
+ onShutdown?: () => void;
52
+ }
53
+
54
+ interface Session {
55
+ info: SessionInfo;
56
+ cwd: string;
57
+ pty: PtyProcess | null;
58
+ log: ScrollbackLog;
59
+ /** aid → its connection. */
60
+ attachments: Map<string, Conn>;
61
+ }
62
+
63
+ class Conn {
64
+ readonly aids = new Set<string>();
65
+ constructor(readonly socket: net.Socket) {}
66
+ send(o: unknown): void {
67
+ if (this.socket.destroyed) return;
68
+ this.socket.write(frame(o));
69
+ if (this.socket.writableLength > MAX_BACKLOG_BYTES) this.socket.destroy();
70
+ }
71
+ }
72
+
73
+ class Refusal extends Error {
74
+ constructor(
75
+ readonly code: HostErrorCode,
76
+ message: string,
77
+ ) {
78
+ super(message);
79
+ }
80
+ }
81
+
82
+ const isInt = (n: unknown, min: number, max: number): n is number =>
83
+ typeof n === "number" && Number.isInteger(n) && n >= min && n <= max;
84
+
85
+ function pidAlive(pid: number): boolean {
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ } catch (e) {
90
+ return (e as NodeJS.ErrnoException).code === "EPERM";
91
+ }
92
+ }
93
+
94
+ /** Whether something answers on the socket. */
95
+ export function socketAnswers(sock: string, ms = 1000): Promise<boolean> {
96
+ return new Promise((resolve) => {
97
+ const s = net.connect(sock);
98
+ const done = (v: boolean) => {
99
+ clearTimeout(t);
100
+ s.destroy();
101
+ resolve(v);
102
+ };
103
+ const t = setTimeout(() => done(false), ms);
104
+ s.once("connect", () => done(true));
105
+ s.once("error", () => done(false));
106
+ });
107
+ }
108
+
109
+ /**
110
+ * The audit of a start's `--keep-env` names: "A, B (credential-shaped)",
111
+ * or "" for none. Names only: a value never reaches the host's log.
112
+ */
113
+ function keptEnvAudit(v: unknown): string {
114
+ if (!Array.isArray(v)) return "";
115
+ return v
116
+ .filter(
117
+ (k): k is { name: string; credentialShaped?: unknown } =>
118
+ !!k &&
119
+ typeof (k as { name?: unknown }).name === "string" &&
120
+ /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test((k as { name: string }).name),
121
+ )
122
+ .map(
123
+ (k) =>
124
+ `${k.name}${k.credentialShaped === true ? " (credential-shaped)" : ""}`,
125
+ )
126
+ .join(", ");
127
+ }
128
+
129
+ function printableActor(v: unknown): string {
130
+ return (
131
+ String(v ?? "unknown")
132
+ .replace(/[^\x20-\x7e]/g, "")
133
+ .slice(0, 80) || "unknown"
134
+ );
135
+ }
136
+
137
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
138
+
139
+ export class SessionHost {
140
+ readonly paths: SessionPaths;
141
+ private readonly sessions = new Map<string, Session>();
142
+ private readonly conns = new Set<Conn>();
143
+ private server: net.Server | null = null;
144
+ private readonly startedAt = new Date().toISOString();
145
+ private closed = false;
146
+
147
+ constructor(private readonly opts: SessionHostOptions) {
148
+ this.paths = sessionPaths(opts.stateDir);
149
+ }
150
+
151
+ private log(line: string): void {
152
+ (this.opts.log ?? ((l: string) => console.log(l)))(line);
153
+ }
154
+
155
+ private get capacity(): number {
156
+ return this.opts.scrollbackBytes ?? DEFAULT_SCROLLBACK_BYTES;
157
+ }
158
+
159
+ /** Serve on the socket, or stand down when a host already answers there. */
160
+ async listen(): Promise<"listening" | "already-running"> {
161
+ const { socket } = this.paths;
162
+ if (Buffer.byteLength(socket) > MAX_SOCKET_PATH)
163
+ throw new Error(
164
+ `the session socket path is ${Buffer.byteLength(socket)} bytes, over the ${MAX_SOCKET_PATH}-byte limit for a unix socket: ${socket}. Use a shorter state dir.`,
165
+ );
166
+ fs.mkdirSync(this.paths.dir, { recursive: true, mode: 0o700 });
167
+ if (await socketAnswers(socket)) return "already-running";
168
+ if (!(await this.takeLock())) return "already-running";
169
+ try {
170
+ if (await socketAnswers(socket)) return "already-running";
171
+ try {
172
+ fs.unlinkSync(socket); // stale: nobody answers on it
173
+ this.log(`replaced a stale socket at ${socket}`);
174
+ } catch {
175
+ /* none there */
176
+ }
177
+ this.load();
178
+ await this.bind(socket);
179
+ return "listening";
180
+ } finally {
181
+ try {
182
+ fs.unlinkSync(this.paths.lock);
183
+ } catch {
184
+ /* gone */
185
+ }
186
+ }
187
+ }
188
+
189
+ /** The start lock; false when another host came up while waiting for it. */
190
+ private async takeLock(): Promise<boolean> {
191
+ const deadline = Date.now() + LOCK_WAIT_MS;
192
+ for (;;) {
193
+ try {
194
+ const fd = fs.openSync(this.paths.lock, "wx", 0o600);
195
+ fs.writeSync(fd, String(process.pid));
196
+ fs.closeSync(fd);
197
+ return true;
198
+ } catch (e) {
199
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
200
+ }
201
+ let holder = NaN;
202
+ try {
203
+ holder = Number(fs.readFileSync(this.paths.lock, "utf-8").trim());
204
+ } catch {
205
+ continue; // released between the two calls
206
+ }
207
+ if (
208
+ !Number.isInteger(holder) ||
209
+ !pidAlive(holder) ||
210
+ Date.now() > deadline
211
+ ) {
212
+ try {
213
+ fs.unlinkSync(this.paths.lock);
214
+ } catch {
215
+ /* someone else took it over */
216
+ }
217
+ continue;
218
+ }
219
+ if (await socketAnswers(this.paths.socket, 200)) return false;
220
+ await sleep(50);
221
+ }
222
+ }
223
+
224
+ private bind(socket: string): Promise<void> {
225
+ const server = net.createServer((s) => this.accept(s));
226
+ this.server = server;
227
+ return new Promise((resolve, reject) => {
228
+ // Owner-only from the moment the socket exists.
229
+ const umask = process.umask(0o177);
230
+ server.once("error", (e) => {
231
+ process.umask(umask);
232
+ reject(e);
233
+ });
234
+ server.listen(socket, () => {
235
+ process.umask(umask);
236
+ fs.chmodSync(socket, 0o600);
237
+ resolve();
238
+ });
239
+ });
240
+ }
241
+
242
+ /** Records from an earlier host: their PTYs ended with it. */
243
+ private load(): void {
244
+ let files: string[] = [];
245
+ try {
246
+ files = fs.readdirSync(this.paths.dir).filter((f) => f.endsWith(".json"));
247
+ } catch {
248
+ return;
249
+ }
250
+ for (const f of files) {
251
+ try {
252
+ const rec = JSON.parse(
253
+ fs.readFileSync(path.join(this.paths.dir, f), "utf-8"),
254
+ ) as {
255
+ id: string;
256
+ name: string;
257
+ cwd: string;
258
+ pid: number;
259
+ startedAt: string;
260
+ exited?: boolean;
261
+ exitCode?: number;
262
+ prompted?: boolean;
263
+ };
264
+ if (typeof rec.id !== "string" || typeof rec.name !== "string")
265
+ continue;
266
+ const logFile = path.join(this.paths.dir, `${rec.id}.log`);
267
+ const s: Session = {
268
+ info: {
269
+ ref: { backend: BACKEND_ID, id: rec.id },
270
+ name: rec.name,
271
+ pid: rec.pid,
272
+ startedAt: rec.startedAt,
273
+ exited: true,
274
+ ...(typeof rec.exitCode === "number"
275
+ ? { exitCode: rec.exitCode }
276
+ : {}),
277
+ ...(rec.prompted === true ? { prompted: true } : {}),
278
+ },
279
+ cwd: rec.cwd,
280
+ pty: null,
281
+ log: new ScrollbackLog(
282
+ logFile,
283
+ ScrollbackLog.load(logFile, this.capacity),
284
+ {
285
+ flushMs: this.opts.flushMs,
286
+ },
287
+ ),
288
+ attachments: new Map(),
289
+ };
290
+ this.sessions.set(rec.id, s);
291
+ if (!rec.exited) this.persist(s);
292
+ } catch {
293
+ /* an unreadable record is skipped, not fatal */
294
+ }
295
+ }
296
+ }
297
+
298
+ private persist(s: Session): void {
299
+ const rec = {
300
+ id: s.info.ref.id,
301
+ name: s.info.name,
302
+ cwd: s.cwd,
303
+ pid: s.info.pid,
304
+ startedAt: s.info.startedAt,
305
+ ...(s.info.exited ? { exited: true } : {}),
306
+ ...(s.info.exitCode !== undefined ? { exitCode: s.info.exitCode } : {}),
307
+ ...(s.info.prompted ? { prompted: true } : {}),
308
+ };
309
+ try {
310
+ fs.writeFileSync(
311
+ path.join(this.paths.dir, `${s.info.ref.id}.json`),
312
+ JSON.stringify(rec, null, 2) + "\n",
313
+ { mode: 0o600 },
314
+ );
315
+ } catch (e) {
316
+ this.log(
317
+ `could not save session ${s.info.ref.id}: ${(e as Error).message}`,
318
+ );
319
+ }
320
+ }
321
+
322
+ private accept(socket: net.Socket): void {
323
+ const conn = new Conn(socket);
324
+ this.conns.add(conn);
325
+ const reader = new LineReader((line) => this.onLine(conn, line));
326
+ socket.on("data", (chunk) => {
327
+ if (!reader.push(chunk)) socket.destroy();
328
+ });
329
+ socket.on("error", () => undefined);
330
+ socket.on("close", () => {
331
+ for (const aid of conn.aids)
332
+ for (const s of this.sessions.values()) s.attachments.delete(aid);
333
+ this.conns.delete(conn);
334
+ });
335
+ }
336
+
337
+ private onLine(conn: Conn, line: string): void {
338
+ let req: Request;
339
+ try {
340
+ req = JSON.parse(line) as Request;
341
+ } catch {
342
+ conn.send({
343
+ id: null,
344
+ ok: false,
345
+ code: "bad-request",
346
+ error: "not JSON",
347
+ });
348
+ return;
349
+ }
350
+ const id = typeof req?.id === "number" ? req.id : null;
351
+ try {
352
+ if (req.op === "shutdown") {
353
+ conn.send({ id, ok: true });
354
+ setImmediate(() => {
355
+ void this.close().then(() => this.opts.onShutdown?.());
356
+ });
357
+ return;
358
+ }
359
+ if (req.v !== PROTOCOL_VERSION)
360
+ throw new Refusal(
361
+ "protocol",
362
+ `this session host speaks protocol v${PROTOCOL_VERSION}, the client v${String(req.v)}`,
363
+ );
364
+ conn.send({ id, ok: true, ...this.dispatch(conn, req) });
365
+ } catch (e) {
366
+ conn.send({
367
+ id,
368
+ ok: false,
369
+ code: e instanceof Refusal ? e.code : "bad-request",
370
+ error: (e as Error).message,
371
+ });
372
+ }
373
+ }
374
+
375
+ private session(ref: unknown): Session {
376
+ const s = typeof ref === "string" ? this.sessions.get(ref) : undefined;
377
+ if (!s)
378
+ throw new Refusal("not-found", `no session with id "${String(ref)}"`);
379
+ return s;
380
+ }
381
+
382
+ private dispatch(conn: Conn, req: Request): Record<string, unknown> {
383
+ switch (req.op) {
384
+ case "hello":
385
+ return {
386
+ v: PROTOCOL_VERSION,
387
+ pid: process.pid,
388
+ startedAt: this.startedAt,
389
+ };
390
+ case "list":
391
+ return {
392
+ sessions: [...this.sessions.values()]
393
+ .map((s) => s.info)
394
+ .sort((a, b) => a.startedAt.localeCompare(b.startedAt)),
395
+ };
396
+ case "start":
397
+ return { info: this.start(req) };
398
+ case "attach":
399
+ return { info: this.attach(conn, req) };
400
+ case "detach": {
401
+ const aid = String(req.aid);
402
+ conn.aids.delete(aid);
403
+ for (const s of this.sessions.values()) s.attachments.delete(aid);
404
+ return {};
405
+ }
406
+ case "write": {
407
+ const s = this.session(req.ref);
408
+ if (s.pty && !s.info.exited && typeof req.data === "string") {
409
+ const bytes = Buffer.from(req.data, "base64");
410
+ s.pty.write(bytes);
411
+ // A line end is a prompt sent; a terminal's own replies to the
412
+ // program's queries (escape sequences) carry none.
413
+ if (
414
+ !s.info.prompted &&
415
+ (bytes.includes(0x0d) || bytes.includes(0x0a))
416
+ ) {
417
+ s.info.prompted = true;
418
+ this.persist(s);
419
+ }
420
+ }
421
+ return {};
422
+ }
423
+ case "resize": {
424
+ const s = this.session(req.ref);
425
+ if (!isInt(req.cols, 1, 10_000) || !isInt(req.rows, 1, 10_000))
426
+ throw new Refusal("bad-request", "resize needs whole cols and rows");
427
+ if (s.pty && !s.info.exited) s.pty.resize(req.cols, req.rows);
428
+ return {};
429
+ }
430
+ case "signal": {
431
+ const s = this.session(req.ref);
432
+ const sig = String(req.signal);
433
+ if (!(SESSION_SIGNALS as readonly string[]).includes(sig))
434
+ throw new Refusal(
435
+ "bad-request",
436
+ `signal must be one of ${SESSION_SIGNALS.join(", ")}`,
437
+ );
438
+ if (s.info.exited || !s.pty)
439
+ throw new Refusal("exited", `${s.info.name} has already exited`);
440
+ s.pty.kill(`SIG${sig}`);
441
+ return {};
442
+ }
443
+ case "scrollback": {
444
+ const s = this.session(req.ref);
445
+ const max = isInt(req.max, 0, Number.MAX_SAFE_INTEGER)
446
+ ? req.max
447
+ : this.capacity;
448
+ return { data: s.log.ring.snapshot(max).toString("base64") };
449
+ }
450
+ case "forget": {
451
+ const s = this.session(req.ref);
452
+ if (!s.info.exited)
453
+ throw new Refusal(
454
+ "bad-request",
455
+ `${s.info.name} is running: kill it first`,
456
+ );
457
+ this.forget(s);
458
+ return {};
459
+ }
460
+ default:
461
+ throw new Refusal("bad-request", `unknown op "${String(req.op)}"`);
462
+ }
463
+ }
464
+
465
+ private forget(s: Session): void {
466
+ this.sessions.delete(s.info.ref.id);
467
+ for (const ext of [".json", ".log"])
468
+ try {
469
+ fs.unlinkSync(path.join(this.paths.dir, `${s.info.ref.id}${ext}`));
470
+ } catch {
471
+ /* never written */
472
+ }
473
+ }
474
+
475
+ private start(req: Request): SessionInfo {
476
+ const spec = (req.spec ?? {}) as Record<string, unknown>;
477
+ const { name, cwd, argv, env } = spec;
478
+ if (typeof name !== "string" || !name)
479
+ throw new Refusal("bad-request", "start needs a name");
480
+ if (
481
+ !Array.isArray(argv) ||
482
+ !argv.length ||
483
+ !argv.every((a): a is string => typeof a === "string")
484
+ )
485
+ throw new Refusal("bad-request", "start needs an argv of strings");
486
+ if (
487
+ typeof cwd !== "string" ||
488
+ !fs.statSync(cwd, { throwIfNoEntry: false })?.isDirectory()
489
+ )
490
+ throw new Refusal(
491
+ "bad-request",
492
+ `cwd "${String(cwd)}" is not a directory`,
493
+ );
494
+ if (
495
+ !env ||
496
+ typeof env !== "object" ||
497
+ !Object.values(env).every((v) => typeof v === "string")
498
+ )
499
+ throw new Refusal("bad-request", "start needs an env of strings");
500
+ const cols = isInt(spec.cols, 1, 10_000) ? spec.cols : 80;
501
+ const rows = isInt(spec.rows, 1, 10_000) ? spec.rows : 24;
502
+
503
+ const same = [...this.sessions.values()].filter(
504
+ (s) => s.info.name === name,
505
+ );
506
+ if (same.some((s) => !s.info.exited))
507
+ throw new Refusal("exists", `${name} is already running`);
508
+ const live = [...this.sessions.values()].filter(
509
+ (s) => !s.info.exited,
510
+ ).length;
511
+ if (req.limit !== undefined) {
512
+ if (!isInt(req.limit, 1, 100_000))
513
+ throw new Refusal(
514
+ "bad-request",
515
+ "limit must be a whole number of at least 1",
516
+ );
517
+ if (live >= req.limit)
518
+ throw new Refusal(
519
+ "cap",
520
+ `${live} session${live === 1 ? " is" : "s are"} running and agents.max_concurrent is ${req.limit}`,
521
+ );
522
+ }
523
+ const load = this.opts.pty();
524
+ if (!load.available) throw new Refusal("unavailable", load.reason);
525
+
526
+ let pty: PtyProcess;
527
+ try {
528
+ pty = load.factory(argv[0], argv.slice(1), {
529
+ cwd,
530
+ env: env as Record<string, string>,
531
+ cols,
532
+ rows,
533
+ });
534
+ } catch (e) {
535
+ throw new Refusal(
536
+ "spawn",
537
+ `could not start ${argv[0]}: ${(e as Error).message}`,
538
+ );
539
+ }
540
+ for (const s of same) this.forget(s);
541
+
542
+ let id = randomBytes(4).toString("hex");
543
+ while (this.sessions.has(id)) id = randomBytes(4).toString("hex");
544
+ const logFile = path.join(this.paths.dir, `${id}.log`);
545
+ const s: Session = {
546
+ info: {
547
+ ref: { backend: BACKEND_ID, id },
548
+ name,
549
+ pid: pty.pid,
550
+ startedAt: new Date().toISOString(),
551
+ },
552
+ cwd,
553
+ pty,
554
+ log: new ScrollbackLog(
555
+ logFile,
556
+ ScrollbackLog.load(logFile, this.capacity),
557
+ {
558
+ flushMs: this.opts.flushMs,
559
+ },
560
+ ),
561
+ attachments: new Map(),
562
+ };
563
+ this.sessions.set(id, s);
564
+ this.persist(s);
565
+ pty.onData((d) => {
566
+ s.log.append(d);
567
+ if (!s.attachments.size) return;
568
+ const data = d.toString("base64");
569
+ for (const [aid, c] of s.attachments) c.send({ ev: "data", aid, data });
570
+ });
571
+ pty.onExit(({ exitCode, signal }) => {
572
+ setTimeout(() => {
573
+ s.info.exited = true;
574
+ s.info.exitCode = signal ? 128 + signal : exitCode;
575
+ s.pty = null;
576
+ s.log.flush();
577
+ if (this.sessions.get(id) === s) this.persist(s);
578
+ for (const [aid, c] of s.attachments)
579
+ c.send({ ev: "exit", aid, exitCode: s.info.exitCode });
580
+ s.attachments.clear();
581
+ this.log(`session ${id} (${name}) exited (${s.info.exitCode})`);
582
+ }, EXIT_SETTLE_MS);
583
+ });
584
+ this.log(`session ${id} (${name}) started, pid ${pty.pid}`);
585
+ const kept = keptEnvAudit(req.keptEnv);
586
+ if (kept)
587
+ this.log(
588
+ `session ${id} (${name}) --keep-env ${kept} · by ${printableActor(req.actor)}`,
589
+ );
590
+ return s.info;
591
+ }
592
+
593
+ private attach(conn: Conn, req: Request): SessionInfo {
594
+ const s = this.session(req.ref);
595
+ const aid = typeof req.aid === "string" ? req.aid : "";
596
+ if (!aid || conn.aids.has(aid))
597
+ throw new Refusal("bad-request", "attach needs a new aid");
598
+ const max = isInt(req.replay, 0, Number.MAX_SAFE_INTEGER)
599
+ ? req.replay
600
+ : this.capacity;
601
+ const replay = s.log.ring.snapshot(max);
602
+ if (replay.length)
603
+ conn.send({ ev: "data", aid, data: replay.toString("base64") });
604
+ if (s.info.exited) {
605
+ conn.send({ ev: "exit", aid, exitCode: s.info.exitCode });
606
+ } else {
607
+ conn.aids.add(aid);
608
+ s.attachments.set(aid, conn);
609
+ }
610
+ return s.info;
611
+ }
612
+
613
+ /** Stop serving: running sessions get SIGHUP, scrollback is flushed. */
614
+ async close(): Promise<void> {
615
+ if (this.closed) return;
616
+ this.closed = true;
617
+ for (const s of this.sessions.values()) {
618
+ if (s.pty && !s.info.exited) {
619
+ try {
620
+ s.pty.kill("SIGHUP");
621
+ } catch {
622
+ /* gone */
623
+ }
624
+ }
625
+ s.log.flush();
626
+ }
627
+ for (const c of this.conns) c.socket.destroy();
628
+ const server = this.server;
629
+ if (server) await new Promise<void>((r) => server.close(() => r()));
630
+ }
631
+ }
632
+
633
+ /**
634
+ * `<cli> sessions host`: serve until stopped (SIGTERM, SIGINT, or a
635
+ * `shutdown` request). Resolves 0 once listening, or at once when a host is
636
+ * already running; 1 when it cannot listen.
637
+ */
638
+ export async function runSessionHost(
639
+ opts: Omit<SessionHostOptions, "onShutdown">,
640
+ ): Promise<number> {
641
+ const log =
642
+ opts.log ??
643
+ ((l: string) => console.log(`${new Date().toISOString()} ${l}`));
644
+ const host = new SessionHost({
645
+ ...opts,
646
+ log,
647
+ onShutdown: () => process.exit(0),
648
+ });
649
+ let state: "listening" | "already-running";
650
+ try {
651
+ state = await host.listen();
652
+ } catch (e) {
653
+ log(`session host could not start: ${(e as Error).message}`);
654
+ return 1;
655
+ }
656
+ if (state === "already-running") {
657
+ log(`a session host is already running on ${host.paths.socket}`);
658
+ return 0;
659
+ }
660
+ const stop = () => void host.close().then(() => process.exit(0));
661
+ process.on("SIGTERM", stop);
662
+ process.on("SIGINT", stop);
663
+ // No terminal to hang up on: it was started detached.
664
+ process.on("SIGHUP", () => undefined);
665
+ log(`session host ${process.pid} listening on ${host.paths.socket}`);
666
+ return 0;
667
+ }