agent-log-viewer 1.0.1 → 1.0.2

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 (76) hide show
  1. package/README.md +19 -21
  2. package/bin/cli.mjs +212 -12
  3. package/bin/server-runtime.mjs +51 -0
  4. package/bin/server-runtime.test.ts +190 -0
  5. package/dist/mcp-server.mjs +58 -25
  6. package/dist/runtime-host.mjs +40301 -0
  7. package/dist/standalone/.next/BUILD_ID +1 -1
  8. package/dist/standalone/.next/app-path-routes-manifest.json +13 -13
  9. package/dist/standalone/.next/build-manifest.json +2 -2
  10. package/dist/standalone/.next/prerender-manifest.json +3 -3
  11. package/dist/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  12. package/dist/standalone/.next/server/app/_global-error.html +1 -1
  13. package/dist/standalone/.next/server/app/_global-error.rsc +1 -1
  14. package/dist/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  15. package/dist/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  16. package/dist/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  17. package/dist/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  18. package/dist/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  19. package/dist/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  20. package/dist/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  21. package/dist/standalone/.next/server/app/_not-found.html +1 -1
  22. package/dist/standalone/.next/server/app/_not-found.rsc +1 -1
  23. package/dist/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  24. package/dist/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  25. package/dist/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  26. package/dist/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  27. package/dist/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  28. package/dist/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  29. package/dist/standalone/.next/server/app/api/accounts/claude/[id]/status/route.js +3 -3
  30. package/dist/standalone/.next/server/app/api/accounts/claude/login/[operationId]/input/route.js +3 -3
  31. package/dist/standalone/.next/server/app/api/accounts/claude/login/[operationId]/route.js +3 -3
  32. package/dist/standalone/.next/server/app/api/accounts/claude/terminal/route.js +3 -3
  33. package/dist/standalone/.next/server/app/api/accounts/codex/terminal/route.js +3 -3
  34. package/dist/standalone/.next/server/app/api/limits/history/route.js +3 -3
  35. package/dist/standalone/.next/server/app/api/limits/route.js +3 -3
  36. package/dist/standalone/.next/server/app/api/orchestrator/seat/route.js +1 -1
  37. package/dist/standalone/.next/server/app/api/runtime/deployments/[deploymentId]/route.js +1 -1
  38. package/dist/standalone/.next/server/app/api/runtime/deployments/capabilities/v1/route.js +1 -1
  39. package/dist/standalone/.next/server/app/api/runtime/deployments/route.js +1 -1
  40. package/dist/standalone/.next/server/app/api/runtime/operations/route.js +1 -1
  41. package/dist/standalone/.next/server/app/api/runtime/snapshot/route.js +1 -1
  42. package/dist/standalone/.next/server/app/api/runtime/stream/route.js +1 -1
  43. package/dist/standalone/.next/server/app/api/spawn/policy/route.js +3 -3
  44. package/dist/standalone/.next/server/app/api/tasks/route.js +2 -2
  45. package/dist/standalone/.next/server/app/index.html +1 -1
  46. package/dist/standalone/.next/server/app/index.rsc +2 -2
  47. package/dist/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  48. package/dist/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
  49. package/dist/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  50. package/dist/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  51. package/dist/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  52. package/dist/standalone/.next/server/app/page.js +15 -12
  53. package/dist/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/dist/standalone/.next/server/app-paths-manifest.json +13 -13
  55. package/dist/standalone/.next/server/chunks/2338.js +4 -1
  56. package/dist/standalone/.next/server/chunks/2904.js +3 -3
  57. package/dist/standalone/.next/server/chunks/3740.js +7 -7
  58. package/dist/standalone/.next/server/chunks/4012.js +3 -3
  59. package/dist/standalone/.next/server/chunks/4972.js +1 -1
  60. package/dist/standalone/.next/server/chunks/5689.js +2 -2
  61. package/dist/standalone/.next/server/chunks/7471.js +6 -6
  62. package/dist/standalone/.next/server/chunks/7733.js +3 -3
  63. package/dist/standalone/.next/server/chunks/8797.js +1 -1
  64. package/dist/standalone/.next/server/chunks/8817.js +1 -1
  65. package/dist/standalone/.next/server/chunks/9364.js +11 -11
  66. package/dist/standalone/.next/server/chunks/9411.js +2 -2
  67. package/dist/standalone/.next/server/chunks/9733.js +3 -3
  68. package/dist/standalone/.next/server/middleware-build-manifest.js +1 -1
  69. package/dist/standalone/.next/server/pages/404.html +1 -1
  70. package/dist/standalone/.next/server/pages/500.html +1 -1
  71. package/dist/standalone/.next/server/server-reference-manifest.json +1 -1
  72. package/dist/standalone/.next/static/chunks/app/{page-ac9463b910bf83fe.js → page-7ba2538fa87e3d62.js} +17 -14
  73. package/dist/standalone/package.json +1 -1
  74. package/package.json +1 -1
  75. /package/dist/standalone/.next/static/{kt9OPe8iwfpNcdOX9ygvb → 2ybXlxpylgBaGz43zp2fM}/_buildManifest.js +0 -0
  76. /package/dist/standalone/.next/static/{kt9OPe8iwfpNcdOX9ygvb → 2ybXlxpylgBaGz43zp2fM}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -95,22 +95,25 @@ bunx agent-log-viewer
95
95
  npx agent-log-viewer
96
96
  ```
97
97
 
98
- This starts the server on `127.0.0.1:8898` and opens your browser. Both launch
99
- paths require Bun to be installed because the authoritative state stores use
100
- Bun SQLite.
98
+ This starts the server on `127.0.0.1:8898` and opens your browser. The CLI also
99
+ starts and supervises the packaged structured runtime host, including restart
100
+ backoff and Ctrl-C cleanup. Pipelines and the orchestrator work from this
101
+ installation without Docker. Both launch paths require Bun because the runtime
102
+ journal and authoritative state stores use Bun SQLite.
101
103
 
102
104
  ### From a local clone
103
105
 
104
106
  ```bash
105
107
  bun install
106
108
  bun run build
107
- bun run start -- --port 8898 --hostname 127.0.0.1
109
+ bun bin/cli.mjs --no-open --port 8898 --hostname 127.0.0.1
108
110
  # open http://127.0.0.1:8898/
109
111
  ```
110
112
 
111
- `start` serves the output of the last `build`, so run `build` first. For
112
- development, `bun dev` runs the app with hot reload (it needs a high OS
113
- file-watch limit for large home directories).
113
+ The CLI serves the output of the last `build`, so run `build` first. For
114
+ development, `bun dev` runs the app with hot reload and expects a separately
115
+ managed runtime host (it needs a high OS file-watch limit for large home
116
+ directories).
114
117
 
115
118
  The Viewer server runs on Bun. The runtime journal and hot state collections
116
119
  use `bun:sqlite`, and macOS process ownership uses the kernel's microsecond
@@ -119,20 +122,15 @@ feature-flag configuration.
119
122
 
120
123
  ### Spawn transport
121
124
 
122
- Agents launch through a structured runtime host no tmux pane between the
123
- Viewer and the agent wherever a deployment can serve one. The transport is
124
- chosen by capability: a deployment that declares `LLV_RUNTIME_HOST_SOCKET`
125
- spawns structured, and one without a host still spawns through tmux instead of
126
- failing. Structured hosting itself is on by default; `LLV_STRUCTURED_HOSTS=0`,
127
- `LLV_RUNTIME_EVENTS=0`, and `NEXT_PUBLIC_RUNTIME_UI=0` are rollback switches.
128
- Each of the three sends the implicit choice back to tmux, so reaching for one
129
- during an incident never costs the ability to spawn. They accept the spellings
130
- an env file actually produces — `0`, `" 0 "`, `'0'`, `false`, `off`, `no` and
131
- an unset variable means the default is on.
132
-
133
- `LLV_SPAWN_TRANSPORT=tmux|structured` overrides the choice. An explicit
134
- `structured` without a reachable host returns a gap naming what is missing
135
- rather than quietly downgrading the request.
125
+ Agents launch through a structured runtime host. The installed CLI supervises
126
+ that host with the same Bun executable as the Viewer and places its Unix socket
127
+ and runtime journal under the Viewer state directory with installation-specific
128
+ names. Ambient deployment runtime settings are ignored so separate bunx
129
+ installations cannot attach to each other's host or claim each other's journal
130
+ epoch. Startup fails clearly when Bun, the packaged host entry, the managed
131
+ socket, or its directory permissions are unavailable.
132
+ The CLI log carries the host failure and the pipeline card directs the operator
133
+ to it. There is no tmux fallback.
136
134
 
137
135
  ### Connect an orchestrator through MCP
138
136
 
package/bin/cli.mjs CHANGED
@@ -3,16 +3,18 @@
3
3
  import { spawn } from "node:child_process";
4
4
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync } from "node:fs";
5
5
  import http from "node:http";
6
+ import net from "node:net";
6
7
  import { homedir } from "node:os";
7
8
  import { dirname, join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
 
10
11
  import { detectTailscale, getToken, readStatus, serve as serveTailscale, TailscaleError } from "./tailscale.mjs";
11
12
  import {
13
+ cliRuntimeHostConfig,
14
+ cliRuntimeHostEnvironment,
12
15
  discardWakatimeEnvironmentCredential,
13
16
  viewerChildProcessOptions,
14
17
  viewerServerBunRuntime,
15
- withoutWakatimeCredential,
16
18
  } from "./server-runtime.mjs";
17
19
 
18
20
  discardWakatimeEnvironmentCredential();
@@ -27,6 +29,11 @@ const READINESS_INTERVAL_MS = 200;
27
29
  // Reusing READINESS_INTERVAL_MS here made every probe abort before the healthy
28
30
  // server could answer, so startup always "timed out" and killed its own server.
29
31
  const READINESS_PROBE_TIMEOUT_MS = 5_000;
32
+ const RUNTIME_HOST_READINESS_TIMEOUT_MS = 15_000;
33
+ const RUNTIME_HOST_READINESS_INTERVAL_MS = 100;
34
+ const RUNTIME_HOST_RESTART_BASE_MS = 500;
35
+ const RUNTIME_HOST_RESTART_MAX_MS = 10_000;
36
+ const RUNTIME_HOST_STABLE_UPTIME_MS = 30_000;
30
37
 
31
38
  const cliPath = fileURLToPath(import.meta.url);
32
39
  const cliDir = dirname(cliPath);
@@ -73,6 +80,13 @@ Options:
73
80
  tsCookie: () => " After the first open the key is stored in a cookie for 30 days.",
74
81
  nonLocalWarn: () => "Warning: a non-local address exposes the viewer to the network, so access-key mode is forced on.",
75
82
  serverNotReady: () => "Server not ready.",
83
+ runtimeHostEntryMissing: () => "The runtime host is missing from this install. Reinstall agent-log-viewer and try again.",
84
+ runtimeHostStartFail: (detail) => `Couldn't start the structured runtime host: ${detail}`,
85
+ runtimeHostTimeout: (socketPath) => `the runtime host did not bind ${socketPath} within ${RUNTIME_HOST_READINESS_TIMEOUT_MS / 1_000} seconds; check the socket directory permissions`,
86
+ runtimeHostExited: (detail) => `the runtime host exited before its socket was ready${detail ? `: ${detail}` : ""}`,
87
+ runtimeHostOwnerMismatch: (ownerPid, childPid) => `the runtime host socket is owned by pid ${ownerPid}, while this CLI spawned pid ${childPid}; stop the other agent-log-viewer instance for this installation and try again`,
88
+ runtimeHostRestart: (delay, detail) => `[runtime host] ${detail}; restarting in ${delay}ms`,
89
+ runtimeHostRestartFail: (detail) => `[runtime host] restart failed: ${detail}`,
76
90
  },
77
91
  uk: {
78
92
  usage: () => `Використання: agent-log-viewer [опції]
@@ -104,6 +118,13 @@ Options:
104
118
  tsCookie: () => " Після першого відкриття ключ зберігається у cookie на 30 днів.",
105
119
  nonLocalWarn: () => "Увага: нелокальна адреса відкриває viewer для мережі, тому режим ключа доступу увімкнено примусово.",
106
120
  serverNotReady: () => "Сервер не готовий.",
121
+ runtimeHostEntryMissing: () => "У цьому пакеті немає runtime host. Перевстановіть agent-log-viewer і повторіть спробу.",
122
+ runtimeHostStartFail: (detail) => `Не вдалося запустити structured runtime host: ${detail}`,
123
+ runtimeHostTimeout: (socketPath) => `runtime host не створив ${socketPath} за ${RUNTIME_HOST_READINESS_TIMEOUT_MS / 1_000} секунд; перевірте права каталогу сокета`,
124
+ runtimeHostExited: (detail) => `runtime host завершився до готовності сокета${detail ? `: ${detail}` : ""}`,
125
+ runtimeHostOwnerMismatch: (ownerPid, childPid) => `сокетом runtime host володіє процес ${ownerPid}, а цей CLI запустив процес ${childPid}; зупиніть інший agent-log-viewer для цієї інсталяції та повторіть спробу`,
126
+ runtimeHostRestart: (delay, detail) => `[runtime host] ${detail}; повторний запуск за ${delay} мс`,
127
+ runtimeHostRestartFail: (detail) => `[runtime host] помилка повторного запуску: ${detail}`,
107
128
  },
108
129
  };
109
130
 
@@ -255,9 +276,9 @@ function resolveServer(packageRoot) {
255
276
  };
256
277
  }
257
278
 
258
- function buildChildEnv(options, runtime, packageRoot) {
279
+ function buildChildEnv(options, runtime, packageRoot, runtimeHostEnvironment) {
259
280
  const env = {
260
- ...withoutWakatimeCredential(process.env),
281
+ ...runtimeHostEnvironment,
261
282
  PORT: String(options.port),
262
283
  // zsh exports HOSTNAME with the machine name on this user's machine; setting it here keeps standalone bound to the requested address.
263
284
  HOSTNAME: options.hostname,
@@ -306,10 +327,10 @@ function buildChildEnv(options, runtime, packageRoot) {
306
327
  return env;
307
328
  }
308
329
 
309
- function startServer(server, options, runtime, tailscaleProcessRef, packageRoot) {
330
+ function startServer(server, options, runtime, tailscaleProcessRef, runtimeHostSupervisor, packageRoot, runtimeHostEnvironment) {
310
331
  const child = spawn(server.command, server.args, viewerChildProcessOptions({
311
332
  cwd: server.cwd,
312
- env: buildChildEnv(options, runtime, packageRoot),
333
+ env: buildChildEnv(options, runtime, packageRoot, runtimeHostEnvironment),
313
334
  stdio: ["ignore", "inherit", "pipe"],
314
335
  }));
315
336
 
@@ -333,7 +354,11 @@ function startServer(server, options, runtime, tailscaleProcessRef, packageRoot)
333
354
  });
334
355
 
335
356
  child.on("error", (error) => {
336
- fail(m.serverStartFail(error.message));
357
+ state.stopping = true;
358
+ void Promise.all([
359
+ tailscaleProcessRef?.current ? stopChild(tailscaleProcessRef.current) : Promise.resolve(),
360
+ runtimeHostSupervisor.stop(),
361
+ ]).finally(() => fail(m.serverStartFail(error.message)));
337
362
  });
338
363
 
339
364
  child.on("exit", async (code, signal) => {
@@ -348,6 +373,7 @@ function startServer(server, options, runtime, tailscaleProcessRef, packageRoot)
348
373
  if (tailscaleProcessRef?.current) {
349
374
  await stopChild(tailscaleProcessRef.current);
350
375
  }
376
+ await runtimeHostSupervisor.stop();
351
377
 
352
378
  if (state.sawAddressInUse) {
353
379
  process.exit(1);
@@ -369,6 +395,156 @@ function wait(ms) {
369
395
  });
370
396
  }
371
397
 
398
+ function probeRuntimeHost(socketPath) {
399
+ return new Promise((resolve) => {
400
+ const socket = net.createConnection(socketPath);
401
+ let settled = false;
402
+ const finish = (ready) => {
403
+ if (settled) return;
404
+ settled = true;
405
+ socket.destroy();
406
+ resolve(ready);
407
+ };
408
+ socket.once("connect", () => finish(true));
409
+ socket.once("error", () => finish(false));
410
+ socket.setTimeout(1_000, () => finish(false));
411
+ });
412
+ }
413
+
414
+ function runtimeHostFenceOwner(socketPath) {
415
+ try {
416
+ const owner = JSON.parse(readFileSync(`${socketPath}.lock`, "utf8"));
417
+ if (!Number.isSafeInteger(owner?.pid) || owner.pid <= 1) return null;
418
+ if (typeof owner.startIdentity !== "string" || !owner.startIdentity.startsWith(`${owner.pid}:`)) return null;
419
+ if (typeof owner.acquisitionId !== "string" || owner.acquisitionId.length < 16) return null;
420
+ return owner;
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+
426
+ function runtimeHostExitDetail(processHandle) {
427
+ const { child, state } = processHandle;
428
+ if (state.spawnError) {
429
+ return state.spawnError.code === "ENOENT"
430
+ ? `Bun executable ${state.command} is unavailable (${state.spawnError.message})`
431
+ : `Bun could not launch the host (${state.spawnError.message})`;
432
+ }
433
+ const outcome = child.signalCode ? `signal ${child.signalCode}` : `exit code ${child.exitCode ?? "unknown"}`;
434
+ const stderr = state.stderrTail
435
+ .split(/\r?\n/)
436
+ .map((line) => line.replace(/\s+/g, " ").trim())
437
+ .filter(Boolean)
438
+ .slice(-3)
439
+ .join(" | ");
440
+ return stderr ? `${outcome}: ${stderr}` : outcome;
441
+ }
442
+
443
+ async function waitForRuntimeHost(socketPath, processHandle = null) {
444
+ const deadline = Date.now() + RUNTIME_HOST_READINESS_TIMEOUT_MS;
445
+ while (Date.now() < deadline) {
446
+ if (processHandle?.state.spawnError) {
447
+ throw new Error(runtimeHostExitDetail(processHandle));
448
+ }
449
+ if (processHandle && (processHandle.child.exitCode !== null || processHandle.child.signalCode !== null)) {
450
+ throw new Error(m.runtimeHostExited(runtimeHostExitDetail(processHandle)));
451
+ }
452
+ if (await probeRuntimeHost(socketPath)) {
453
+ const owner = runtimeHostFenceOwner(socketPath);
454
+ if (!processHandle) return;
455
+ if (owner?.pid === processHandle.child.pid) return;
456
+ if (owner) throw new Error(m.runtimeHostOwnerMismatch(owner.pid, processHandle.child.pid));
457
+ }
458
+ await wait(RUNTIME_HOST_READINESS_INTERVAL_MS);
459
+ }
460
+ throw new Error(m.runtimeHostTimeout(socketPath));
461
+ }
462
+
463
+ function createRuntimeHostSupervisor(config, bunRuntime, environment, packageRoot) {
464
+ let current = null;
465
+ let restartTimer = null;
466
+ let restartFailures = 0;
467
+ let stopping = false;
468
+
469
+ const scheduleRestart = (detail, uptimeMs = 0) => {
470
+ if (stopping || restartTimer) return;
471
+ restartFailures = uptimeMs >= RUNTIME_HOST_STABLE_UPTIME_MS ? 1 : restartFailures + 1;
472
+ const delay = Math.min(
473
+ RUNTIME_HOST_RESTART_BASE_MS * (2 ** Math.max(0, restartFailures - 1)),
474
+ RUNTIME_HOST_RESTART_MAX_MS,
475
+ );
476
+ console.error(m.runtimeHostRestart(delay, detail));
477
+ restartTimer = setTimeout(() => {
478
+ restartTimer = null;
479
+ void launch(false).catch((error) => {
480
+ if (stopping) return;
481
+ const message = error instanceof Error ? error.message : String(error);
482
+ console.error(m.runtimeHostRestartFail(message));
483
+ scheduleRestart(message);
484
+ });
485
+ }, delay);
486
+ };
487
+
488
+ const spawnHost = () => {
489
+ const child = spawn(bunRuntime, ["--bun", config.entrypoint], viewerChildProcessOptions({
490
+ cwd: packageRoot,
491
+ env: environment,
492
+ stdio: ["ignore", "inherit", "pipe"],
493
+ }));
494
+ const state = {
495
+ command: bunRuntime,
496
+ readyAt: null,
497
+ spawnError: null,
498
+ stderrTail: "",
499
+ stopping: false,
500
+ };
501
+ const processHandle = { child, state };
502
+ current = processHandle;
503
+ child.stderr.on("data", (chunk) => {
504
+ state.stderrTail = `${state.stderrTail}${chunk}`.slice(-8_192);
505
+ process.stderr.write(chunk);
506
+ });
507
+ child.once("error", (error) => {
508
+ state.spawnError = error;
509
+ });
510
+ child.once("exit", () => {
511
+ if (current !== processHandle || stopping || state.stopping || state.readyAt === null) return;
512
+ scheduleRestart(runtimeHostExitDetail(processHandle), Date.now() - state.readyAt);
513
+ });
514
+ return processHandle;
515
+ };
516
+
517
+ const launch = async (initial) => {
518
+ const processHandle = spawnHost();
519
+ try {
520
+ await waitForRuntimeHost(config.socketPath, processHandle);
521
+ processHandle.state.readyAt = Date.now();
522
+ if (processHandle.child.exitCode !== null || processHandle.child.signalCode !== null) {
523
+ throw new Error(m.runtimeHostExited(runtimeHostExitDetail(processHandle)));
524
+ }
525
+ } catch (error) {
526
+ await stopChild(processHandle);
527
+ if (initial) throw error;
528
+ throw error;
529
+ }
530
+ };
531
+
532
+ return {
533
+ async start() {
534
+ if (!existsSync(config.entrypoint)) throw new Error(m.runtimeHostEntryMissing());
535
+ await launch(true);
536
+ },
537
+ async stop() {
538
+ stopping = true;
539
+ if (restartTimer) {
540
+ clearTimeout(restartTimer);
541
+ restartTimer = null;
542
+ }
543
+ if (current) await stopChild(current);
544
+ },
545
+ };
546
+ }
547
+
372
548
  function probe(url) {
373
549
  return new Promise((resolve) => {
374
550
  const request = http.get(url, (response) => {
@@ -477,16 +653,17 @@ async function stopChild(processHandle) {
477
653
  });
478
654
  }
479
655
 
480
- async function stopAll(serverProcess, tailscaleProcess) {
656
+ async function stopAll(serverProcess, tailscaleProcess, runtimeHostSupervisor) {
481
657
  await Promise.all([
482
658
  serverProcess ? stopChild(serverProcess) : Promise.resolve(),
483
659
  tailscaleProcess ? stopChild(tailscaleProcess) : Promise.resolve(),
660
+ runtimeHostSupervisor ? runtimeHostSupervisor.stop() : Promise.resolve(),
484
661
  ]);
485
662
  }
486
663
 
487
- function installSignalHandlers(serverProcess, tailscaleProcessRef) {
664
+ function installSignalHandlers(serverProcess, tailscaleProcessRef, runtimeHostSupervisor) {
488
665
  const shutdown = async () => {
489
- await stopAll(serverProcess, tailscaleProcessRef.current);
666
+ await stopAll(serverProcess, tailscaleProcessRef.current, runtimeHostSupervisor);
490
667
  process.exit(0);
491
668
  };
492
669
 
@@ -626,9 +803,32 @@ async function main() {
626
803
  }
627
804
 
628
805
  const server = resolveServer(packageRoot);
806
+ const runtimeHostConfig = cliRuntimeHostConfig(packageRoot);
807
+ const runtimeHostEnvironment = cliRuntimeHostEnvironment(process.env, runtimeHostConfig);
808
+ const runtimeHostSupervisor = createRuntimeHostSupervisor(
809
+ runtimeHostConfig,
810
+ viewerServerBunRuntime(),
811
+ runtimeHostEnvironment,
812
+ packageRoot,
813
+ );
814
+ try {
815
+ await runtimeHostSupervisor.start();
816
+ } catch (error) {
817
+ await runtimeHostSupervisor.stop();
818
+ fail(m.runtimeHostStartFail(error instanceof Error ? error.message : String(error)));
819
+ }
820
+
629
821
  const tailscaleProcessRef = { current: null };
630
- const serverProcess = startServer(server, options, runtime, tailscaleProcessRef, packageRoot);
631
- installSignalHandlers(serverProcess, tailscaleProcessRef);
822
+ const serverProcess = startServer(
823
+ server,
824
+ options,
825
+ runtime,
826
+ tailscaleProcessRef,
827
+ runtimeHostSupervisor,
828
+ packageRoot,
829
+ runtimeHostEnvironment,
830
+ );
831
+ installSignalHandlers(serverProcess, tailscaleProcessRef, runtimeHostSupervisor);
632
832
 
633
833
  if (options.tailscale && runtime.tailscalePath) {
634
834
  tailscaleProcessRef.current = serveTailscale(runtime.tailscalePath, options.port);
@@ -637,7 +837,7 @@ async function main() {
637
837
  try {
638
838
  await waitForReadiness(options.port);
639
839
  } catch (error) {
640
- await stopAll(serverProcess, tailscaleProcessRef.current);
840
+ await stopAll(serverProcess, tailscaleProcessRef.current, runtimeHostSupervisor);
641
841
  fail(error instanceof Error ? error.message : m.serverNotReady());
642
842
  }
643
843
 
@@ -1,3 +1,8 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+
1
6
  export const WAKATIME_CREDENTIAL_ENV = "WAKATIME_API_KEY";
2
7
 
3
8
  /** @param {Record<string, string | undefined>} environment */
@@ -51,3 +56,49 @@ export function viewerServerBunRuntime(options = {}) {
51
56
  const execPath = options.execPath ?? process.execPath;
52
57
  return versions.bun ? execPath : (env.LLV_BUN_EXECUTABLE || "bun");
53
58
  }
59
+
60
+ /**
61
+ * Resolve the runtime host the public CLI will supervise. Ordinary package
62
+ * installs share the Viewer state directory while retaining distinct socket
63
+ * and journal ownership. Ambient deployment runtime configuration never
64
+ * crosses this CLI ownership boundary.
65
+ *
66
+ * @param {string} packageRoot
67
+ * @param {{ env?: Readonly<Record<string, string | undefined>>, home?: string }} [options]
68
+ */
69
+ export function cliRuntimeHostConfig(packageRoot, options = {}) {
70
+ const env = options.env ?? process.env;
71
+ const home = options.home ?? homedir();
72
+ const stateDirectory = env.LLV_STATE_DIR?.trim()
73
+ || join(env.XDG_CONFIG_HOME?.trim() || join(home, ".config"), "agent-log-viewer", "state");
74
+ const installId = createHash("sha256").update(resolve(packageRoot)).digest("hex").slice(0, 16);
75
+ const bundled = join(packageRoot, "dist", "runtime-host.mjs");
76
+ const source = join(packageRoot, "src", "runtime-host", "main.ts");
77
+ return {
78
+ socketPath: join(stateDirectory, `runtime-host-${installId}.sock`),
79
+ journalPath: join(stateDirectory, `runtime-events-${installId}.sqlite`),
80
+ entrypoint: existsSync(bundled) ? bundled : source,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Environment shared by the supervised runtime host and the Viewer server.
86
+ * The CLI is the structured-host activation boundary, including for callers
87
+ * whose shell still carries one of the former rollback values. Its journal
88
+ * follows the same installation boundary as its socket, so a CLI host cannot
89
+ * claim an ambient deployment's host epoch through the shared state directory.
90
+ *
91
+ * @param {Readonly<Record<string, string | undefined>>} base
92
+ * @param {{ socketPath: string, journalPath: string }} config
93
+ */
94
+ export function cliRuntimeHostEnvironment(base, config) {
95
+ return {
96
+ ...withoutWakatimeCredential(base),
97
+ LLV_RUNTIME_HOST_SOCKET: config.socketPath,
98
+ LLV_RUNTIME_JOURNAL: config.journalPath,
99
+ LLV_STRUCTURED_HOSTS: "1",
100
+ LLV_RUNTIME_EVENTS: "1",
101
+ LLV_SPAWN_TRANSPORT: "structured",
102
+ NEXT_PUBLIC_RUNTIME_UI: "1",
103
+ };
104
+ }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
+ import net from "node:net";
3
4
  import os from "node:os";
4
5
  import { fileURLToPath, pathToFileURL } from "node:url";
5
6
 
@@ -10,8 +11,11 @@ import { loadFlows } from "@/lib/flows/store";
10
11
  import { loadArchivedPipelines, loadPipelines } from "@/lib/pipelines/store";
11
12
  import { structuredHostsEnabled as structuredHostsEnabledInApp } from "@/lib/runtime/flags";
12
13
  import { loadWorkflows } from "@/lib/workflows/store";
14
+ import { RuntimeHostFence } from "@/runtime-host/runtimeHostFence";
13
15
 
14
16
  import {
17
+ cliRuntimeHostConfig,
18
+ cliRuntimeHostEnvironment,
15
19
  structuredHostsEnabled as structuredHostsEnabledInLauncher,
16
20
  viewerServerBunRuntime,
17
21
  viewerChildProcessOptions,
@@ -19,6 +23,192 @@ import {
19
23
  withoutWakatimeCredential,
20
24
  } from "./server-runtime.mjs";
21
25
 
26
+ async function availablePort(): Promise<number> {
27
+ const server = net.createServer();
28
+ await new Promise<void>((resolve, reject) => {
29
+ server.once("error", reject);
30
+ server.listen(0, "127.0.0.1", resolve);
31
+ });
32
+ const address = server.address();
33
+ if (!address || typeof address === "string") throw new Error("could not reserve a launcher test port");
34
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
35
+ return address.port;
36
+ }
37
+
38
+ test("the CLI derives a distinct managed runtime host for each install", () => {
39
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "llv-runtime-package-"));
40
+ const firstPackageRoot = path.join(sandbox, "first");
41
+ const secondPackageRoot = path.join(sandbox, "second");
42
+ const stateDirectory = path.join(sandbox, "state");
43
+ const ambientSocket = path.join(sandbox, "operator-runtime.sock");
44
+ const ambientJournal = path.join(sandbox, "operator-runtime.sqlite");
45
+ const firstBundle = path.join(firstPackageRoot, "dist", "runtime-host.mjs");
46
+ const secondBundle = path.join(secondPackageRoot, "dist", "runtime-host.mjs");
47
+ fs.mkdirSync(path.dirname(firstBundle), { recursive: true });
48
+ fs.mkdirSync(path.dirname(secondBundle), { recursive: true });
49
+ fs.writeFileSync(firstBundle, "export {};\n");
50
+ fs.writeFileSync(secondBundle, "export {};\n");
51
+ try {
52
+ const options = {
53
+ env: {
54
+ LLV_STATE_DIR: stateDirectory,
55
+ LLV_RUNTIME_HOST_SOCKET: ambientSocket,
56
+ LLV_RUNTIME_JOURNAL: ambientJournal,
57
+ },
58
+ home: path.join(sandbox, "home"),
59
+ };
60
+ const first = cliRuntimeHostConfig(firstPackageRoot, options);
61
+ const second = cliRuntimeHostConfig(secondPackageRoot, options);
62
+
63
+ expect(first.entrypoint).toBe(firstBundle);
64
+ expect(second.entrypoint).toBe(secondBundle);
65
+ expect(path.dirname(first.socketPath)).toBe(stateDirectory);
66
+ expect(path.dirname(second.socketPath)).toBe(stateDirectory);
67
+ expect(path.basename(first.socketPath)).toMatch(/^runtime-host-[a-f0-9]{16}\.sock$/);
68
+ expect(path.basename(second.socketPath)).toMatch(/^runtime-host-[a-f0-9]{16}\.sock$/);
69
+ expect(path.dirname(first.journalPath)).toBe(stateDirectory);
70
+ expect(path.dirname(second.journalPath)).toBe(stateDirectory);
71
+ expect(path.basename(first.journalPath)).toMatch(/^runtime-events-[a-f0-9]{16}\.sqlite$/);
72
+ expect(path.basename(second.journalPath)).toMatch(/^runtime-events-[a-f0-9]{16}\.sqlite$/);
73
+ expect(first.socketPath).not.toBe(second.socketPath);
74
+ expect(first.journalPath).not.toBe(second.journalPath);
75
+ expect(first.socketPath).not.toBe(ambientSocket);
76
+ expect(second.socketPath).not.toBe(ambientSocket);
77
+ expect(first.journalPath).not.toBe(ambientJournal);
78
+ expect(second.journalPath).not.toBe(ambientJournal);
79
+ } finally {
80
+ fs.rmSync(sandbox, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ test("the CLI uses the source runtime host in a checkout", () => {
85
+ const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "llv-runtime-source-"));
86
+ const source = path.join(packageRoot, "src", "runtime-host", "main.ts");
87
+ const stateDirectory = path.join(packageRoot, "state");
88
+ fs.mkdirSync(path.dirname(source), { recursive: true });
89
+ fs.writeFileSync(source, "export {};\n");
90
+ try {
91
+ const config = cliRuntimeHostConfig(packageRoot, {
92
+ env: { LLV_STATE_DIR: stateDirectory },
93
+ home: path.join(packageRoot, "home"),
94
+ });
95
+ expect(config.entrypoint).toBe(source);
96
+ expect(path.dirname(config.socketPath)).toBe(stateDirectory);
97
+ expect(path.dirname(config.journalPath)).toBe(stateDirectory);
98
+ } finally {
99
+ fs.rmSync(packageRoot, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("the CLI runtime environment owns its socket and journal and strips ambient WakaTime credentials", () => {
104
+ const socketPath = "/runtime/viewer.sock";
105
+ const journalPath = "/runtime/viewer.sqlite";
106
+ const env: Record<string, string | undefined> = cliRuntimeHostEnvironment({
107
+ PATH: "/usr/bin",
108
+ LLV_RUNTIME_HOST_SOCKET: "/runtime/operator.sock",
109
+ LLV_RUNTIME_JOURNAL: "/runtime/operator.sqlite",
110
+ LLV_STRUCTURED_HOSTS: "off",
111
+ LLV_RUNTIME_EVENTS: "0",
112
+ LLV_SPAWN_TRANSPORT: "tmux",
113
+ NEXT_PUBLIC_RUNTIME_UI: "0",
114
+ [WAKATIME_CREDENTIAL_ENV]: "fixture-value",
115
+ }, { socketPath, journalPath });
116
+
117
+ expect(env).toMatchObject({
118
+ PATH: "/usr/bin",
119
+ LLV_RUNTIME_HOST_SOCKET: socketPath,
120
+ LLV_RUNTIME_JOURNAL: journalPath,
121
+ LLV_STRUCTURED_HOSTS: "1",
122
+ LLV_RUNTIME_EVENTS: "1",
123
+ LLV_SPAWN_TRANSPORT: "structured",
124
+ NEXT_PUBLIC_RUNTIME_UI: "1",
125
+ });
126
+ expect(env[WAKATIME_CREDENTIAL_ENV]).toBeUndefined();
127
+ });
128
+
129
+ test("the CLI rejects a ready runtime socket owned by another process", async () => {
130
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "llv-runtime-incumbent-"));
131
+ const packageRoot = path.resolve(import.meta.dir, "..");
132
+ const stateDirectory = path.join(sandbox, "state");
133
+ const environment: Record<string, string | undefined> = {
134
+ ...process.env,
135
+ HOME: path.join(sandbox, "home"),
136
+ XDG_CONFIG_HOME: path.join(sandbox, "config"),
137
+ LLV_STATE_DIR: stateDirectory,
138
+ TMPDIR: path.join(sandbox, "tmp"),
139
+ LLV_LANG: "en",
140
+ LLV_RUNTIME_HOST_FENCE_WAIT_MS: "0",
141
+ };
142
+ const socketPath = cliRuntimeHostConfig(packageRoot, { env: environment, home: environment.HOME }).socketPath;
143
+ const fence = new RuntimeHostFence(`${socketPath}.lock`);
144
+ const listener = net.createServer((socket) => socket.end());
145
+ fs.mkdirSync(environment.HOME!, { recursive: true });
146
+ fs.mkdirSync(environment.XDG_CONFIG_HOME!, { recursive: true });
147
+ fs.mkdirSync(environment.TMPDIR!, { recursive: true });
148
+ fence.acquire();
149
+ await new Promise<void>((resolve, reject) => {
150
+ listener.once("error", reject);
151
+ listener.listen(socketPath, resolve);
152
+ });
153
+
154
+ const child = Bun.spawn([
155
+ process.execPath,
156
+ "--bun",
157
+ path.join(import.meta.dir, "cli.mjs"),
158
+ "--no-open",
159
+ "--port",
160
+ String(await availablePort()),
161
+ ], { cwd: packageRoot, env: environment, stdout: "pipe", stderr: "pipe" });
162
+ try {
163
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
164
+ expect(exitCode).toBe(1);
165
+ expect(stderr).toContain("Couldn't start the structured runtime host");
166
+ expect(stderr).toContain(`runtime host socket is owned by pid ${process.pid}`);
167
+ expect(stderr).toContain("stop the other agent-log-viewer instance");
168
+ } finally {
169
+ if (!child.killed) child.kill();
170
+ await new Promise<void>((resolve, reject) => listener.close((error) => error ? reject(error) : resolve()));
171
+ fence.release();
172
+ fs.rmSync(sandbox, { recursive: true, force: true });
173
+ }
174
+ }, 10_000);
175
+
176
+ test("the CLI names a missing Bun prerequisite before starting the Viewer", async () => {
177
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "llv-runtime-missing-bun-"));
178
+ const nodeSearchPath = (process.env.PATH ?? "")
179
+ .split(path.delimiter)
180
+ .filter((directory) => !path.basename(directory).startsWith("bun-node-"))
181
+ .join(path.delimiter);
182
+ const nodeExecutable = Bun.which("node", { PATH: nodeSearchPath });
183
+ if (!nodeExecutable) throw new Error("the launcher prerequisite test requires Node");
184
+ const environment: Record<string, string | undefined> = {
185
+ ...process.env,
186
+ PATH: nodeSearchPath,
187
+ HOME: path.join(sandbox, "home"),
188
+ XDG_CONFIG_HOME: path.join(sandbox, "config"),
189
+ LLV_STATE_DIR: path.join(sandbox, "state"),
190
+ TMPDIR: path.join(sandbox, "tmp"),
191
+ LLV_BUN_EXECUTABLE: path.join(sandbox, "missing-bun"),
192
+ };
193
+ delete environment.LLV_RUNTIME_HOST_SOCKET;
194
+ const child = Bun.spawn([
195
+ nodeExecutable,
196
+ path.join(import.meta.dir, "cli.mjs"),
197
+ "--no-open",
198
+ "--port",
199
+ String(await availablePort()),
200
+ ], { cwd: path.resolve(import.meta.dir, ".."), env: environment, stdout: "pipe", stderr: "pipe" });
201
+ try {
202
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
203
+ expect(exitCode).toBe(1);
204
+ expect(stderr).toContain("Couldn't start the structured runtime host");
205
+ expect(stderr).toContain("Bun executable");
206
+ expect(stderr).toContain("is unavailable");
207
+ } finally {
208
+ fs.rmSync(sandbox, { recursive: true, force: true });
209
+ }
210
+ }, 10_000);
211
+
22
212
  test("structured hosts select Bun for a CLI process launched by Node", () => {
23
213
  expect(viewerServerBunRuntime({
24
214
  env: { LLV_STRUCTURED_HOSTS: "1" },