labgate 0.5.30 → 0.5.32

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 (81) hide show
  1. package/README.md +48 -0
  2. package/dist/cli.js +616 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/config.d.ts +11 -0
  5. package/dist/lib/config.js +44 -0
  6. package/dist/lib/config.js.map +1 -1
  7. package/dist/lib/container.d.ts +22 -3
  8. package/dist/lib/container.js +373 -67
  9. package/dist/lib/container.js.map +1 -1
  10. package/dist/lib/display-mcp.d.ts +10 -0
  11. package/dist/lib/display-mcp.js +160 -0
  12. package/dist/lib/display-mcp.js.map +1 -0
  13. package/dist/lib/display-store.d.ts +24 -0
  14. package/dist/lib/display-store.js +150 -0
  15. package/dist/lib/display-store.js.map +1 -0
  16. package/dist/lib/explorer-autopilot.d.ts +16 -0
  17. package/dist/lib/explorer-autopilot.js +573 -0
  18. package/dist/lib/explorer-autopilot.js.map +1 -0
  19. package/dist/lib/explorer-claude.d.ts +16 -0
  20. package/dist/lib/explorer-claude.js +361 -0
  21. package/dist/lib/explorer-claude.js.map +1 -0
  22. package/dist/lib/explorer-compare.d.ts +9 -0
  23. package/dist/lib/explorer-compare.js +190 -0
  24. package/dist/lib/explorer-compare.js.map +1 -0
  25. package/dist/lib/explorer-eval.d.ts +23 -0
  26. package/dist/lib/explorer-eval.js +161 -0
  27. package/dist/lib/explorer-eval.js.map +1 -0
  28. package/dist/lib/explorer-gc.d.ts +11 -0
  29. package/dist/lib/explorer-gc.js +304 -0
  30. package/dist/lib/explorer-gc.js.map +1 -0
  31. package/dist/lib/explorer-git.d.ts +14 -0
  32. package/dist/lib/explorer-git.js +136 -0
  33. package/dist/lib/explorer-git.js.map +1 -0
  34. package/dist/lib/explorer-lock.d.ts +5 -0
  35. package/dist/lib/explorer-lock.js +100 -0
  36. package/dist/lib/explorer-lock.js.map +1 -0
  37. package/dist/lib/explorer-mcp.d.ts +11 -0
  38. package/dist/lib/explorer-mcp.js +611 -0
  39. package/dist/lib/explorer-mcp.js.map +1 -0
  40. package/dist/lib/explorer-retention.d.ts +4 -0
  41. package/dist/lib/explorer-retention.js +58 -0
  42. package/dist/lib/explorer-retention.js.map +1 -0
  43. package/dist/lib/explorer-store.d.ts +77 -0
  44. package/dist/lib/explorer-store.js +950 -0
  45. package/dist/lib/explorer-store.js.map +1 -0
  46. package/dist/lib/explorer-types.d.ts +161 -0
  47. package/dist/lib/explorer-types.js +3 -0
  48. package/dist/lib/explorer-types.js.map +1 -0
  49. package/dist/lib/explorer.d.ts +31 -0
  50. package/dist/lib/explorer.js +247 -0
  51. package/dist/lib/explorer.js.map +1 -0
  52. package/dist/lib/results-mcp.d.ts +2 -2
  53. package/dist/lib/results-mcp.js +26 -4
  54. package/dist/lib/results-mcp.js.map +1 -1
  55. package/dist/lib/results-store.d.ts +1 -0
  56. package/dist/lib/results-store.js +87 -3
  57. package/dist/lib/results-store.js.map +1 -1
  58. package/dist/lib/runtime.d.ts +6 -0
  59. package/dist/lib/runtime.js +46 -19
  60. package/dist/lib/runtime.js.map +1 -1
  61. package/dist/lib/test/integration-harness.js +1 -1
  62. package/dist/lib/test/integration-harness.js.map +1 -1
  63. package/dist/lib/ui.d.ts +1 -0
  64. package/dist/lib/ui.html +11231 -4370
  65. package/dist/lib/ui.js +2564 -277
  66. package/dist/lib/ui.js.map +1 -1
  67. package/dist/lib/web-terminal.d.ts +13 -0
  68. package/dist/lib/web-terminal.js +118 -15
  69. package/dist/lib/web-terminal.js.map +1 -1
  70. package/dist/mcp-bundles/display-mcp.bundle.mjs +30209 -0
  71. package/dist/mcp-bundles/explorer-mcp.bundle.mjs +40044 -0
  72. package/dist/mcp-bundles/results-mcp.bundle.mjs +100 -7
  73. package/package.json +4 -3
  74. package/templates/tsp-lab/API_CONTRACT.md +20 -0
  75. package/templates/tsp-lab/EVAL.md +20 -0
  76. package/templates/tsp-lab/PROBLEM.md +18 -0
  77. package/templates/tsp-lab/data/generate_instances.py +51 -0
  78. package/templates/tsp-lab/data/instances.jsonl +12 -0
  79. package/templates/tsp-lab/eval.py +148 -0
  80. package/templates/tsp-lab/solver.py +88 -0
  81. package/templates/tsp-lab/stub-patches/enable_two_opt.patch +14 -0
package/dist/lib/ui.js CHANGED
@@ -45,13 +45,18 @@ const ws_1 = require("ws");
45
45
  const config_js_1 = require("./config.js");
46
46
  const init_js_1 = require("./init.js");
47
47
  const container_js_1 = require("./container.js");
48
+ const runtime_js_1 = require("./runtime.js");
48
49
  const audit_js_1 = require("./audit.js");
49
50
  const slurm_db_js_1 = require("./slurm-db.js");
50
51
  const slurm_poller_js_1 = require("./slurm-poller.js");
51
52
  const results_store_js_1 = require("./results-store.js");
53
+ const display_store_js_1 = require("./display-store.js");
52
54
  const policy_js_1 = require("./policy.js");
53
55
  const license_js_1 = require("./license.js");
54
56
  const web_terminal_js_1 = require("./web-terminal.js");
57
+ const explorer_js_1 = require("./explorer.js");
58
+ const explorer_eval_js_1 = require("./explorer-eval.js");
59
+ const explorer_store_js_1 = require("./explorer-store.js");
55
60
  const log = __importStar(require("./log.js"));
56
61
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
57
62
  const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
@@ -69,6 +74,11 @@ const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
69
74
  const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
70
75
  const IRIS_SAMPLE_DATASET_NAME = 'flowers-iris';
71
76
  const IRIS_SAMPLE_SOURCE_URL = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv';
77
+ const PODMAN_SETUP_TIMEOUT_MS = 30 * 60 * 1000;
78
+ const PODMAN_SETUP_MAX_BUFFER = 16 * 1024 * 1024;
79
+ const EXPLORER_TSP_TEMPLATE_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'templates', 'tsp-lab');
80
+ const EXPLORER_TSP_TEMPLATE_SOURCE_REPO = (0, path_1.join)((0, config_js_1.getExplorerRootDir)(), 'templates', 'tsp-lab-source');
81
+ const EXPLORER_ARTIFACT_READ_MAX_BYTES = 2 * 1024 * 1024;
72
82
  const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
73
83
  '\n' +
74
84
  'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
@@ -85,14 +95,41 @@ function resolveIrisSampleSourceUrl() {
85
95
  let slurmDB = null;
86
96
  let slurmPoller = null;
87
97
  let resultsStore = null;
98
+ let displayStore = null;
88
99
  const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
89
100
  const webTerminalBridges = new Map();
101
+ const WEB_TERMINAL_INIT_RETENTION_MS = 60 * 60 * 1000;
102
+ const WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS = 60 * 60 * 1000;
103
+ const WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER = 64 * 1024 * 1024;
104
+ const WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS = 30 * 60 * 1000;
105
+ const WEB_TERMINAL_AGENT_PREP_MAX_BUFFER = 32 * 1024 * 1024;
106
+ const WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS = 90 * 1000;
107
+ const WEB_TERMINAL_STARTUP_READY_POLL_MS = 120;
108
+ const WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT = 64 * 1024;
109
+ const WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS = 750;
110
+ const WEB_TERMINAL_BUFFER_MAX_BYTES = 512_000;
111
+ const WEB_TERMINAL_HISTORY_MAX_BYTES = 8 * 1024 * 1024;
112
+ const WEB_TERMINAL_HISTORY_CHUNK_BYTES = 8 * 1024;
113
+ const WEB_TERMINAL_HISTORY_PAGE_DEFAULT = 120;
114
+ const WEB_TERMINAL_HISTORY_PAGE_MAX = 600;
115
+ const WEB_TERMINAL_STARTUP_HEADER_RE = /(?:^|\n)\s*LabGate\s*(?:\n|$)/i;
116
+ const WEB_TERMINAL_STARTUP_BLOCKED_RE = /(?:^|\n)\s*Blocked\s+\d+\s+patterns\b/i;
117
+ const CLAUDE_HEADLESS_STDERR_LIMIT = 12_000;
118
+ const webTerminalInitJobs = new Map();
119
+ const webTerminalImagePullLocks = new Map();
120
+ const webTerminalAgentPrepLocks = new Map();
90
121
  function getResultsStore() {
91
122
  if (!resultsStore) {
92
123
  resultsStore = new results_store_js_1.ResultsStore((0, config_js_1.getResultsDbPath)());
93
124
  }
94
125
  return resultsStore;
95
126
  }
127
+ function getDisplayStore() {
128
+ if (!displayStore) {
129
+ displayStore = new display_store_js_1.DisplayStore((0, config_js_1.getDisplayDbPath)());
130
+ }
131
+ return displayStore;
132
+ }
96
133
  function hasCommandInPath(command) {
97
134
  const pathValue = (process.env.PATH || '').trim();
98
135
  if (!pathValue)
@@ -137,6 +174,475 @@ function getSlurmRuntimeStatus() {
137
174
  missingCommands,
138
175
  };
139
176
  }
177
+ function commandErrorDetail(err) {
178
+ return [
179
+ err?.stderr,
180
+ err?.stdout,
181
+ err?.message,
182
+ err?.cause?.stderr,
183
+ err?.cause?.stdout,
184
+ err?.cause?.message,
185
+ ]
186
+ .filter((part) => typeof part === 'string' && part.trim().length > 0)
187
+ .map((part) => String(part).trim())
188
+ .join('\n');
189
+ }
190
+ function isPodmanNotReadyError(error) {
191
+ return /podman is installed but not ready/i.test(error || '');
192
+ }
193
+ async function prepareRuntimeForWebTerminal(preferred) {
194
+ const initial = (0, runtime_js_1.checkRuntime)(preferred);
195
+ if (initial.ok) {
196
+ return { ok: true, initialized: false };
197
+ }
198
+ const canAutoSetupPodman = ((0, os_1.platform)() === 'darwin' &&
199
+ preferred !== 'apptainer' &&
200
+ hasCommandInPath('podman') &&
201
+ isPodmanNotReadyError(initial.error));
202
+ if (!canAutoSetupPodman) {
203
+ return {
204
+ ok: false,
205
+ initialized: false,
206
+ error: initial.error || 'Container runtime unavailable.',
207
+ };
208
+ }
209
+ log.step('Podman runtime not ready. Attempting automatic machine setup for UI launch...');
210
+ try {
211
+ try {
212
+ await execFileAsync('podman', ['machine', 'init'], {
213
+ timeout: PODMAN_SETUP_TIMEOUT_MS,
214
+ maxBuffer: PODMAN_SETUP_MAX_BUFFER,
215
+ });
216
+ }
217
+ catch (err) {
218
+ const detail = commandErrorDetail(err);
219
+ if (!/already exists/i.test(detail)) {
220
+ throw err;
221
+ }
222
+ }
223
+ await execFileAsync('podman', ['machine', 'start'], {
224
+ timeout: PODMAN_SETUP_TIMEOUT_MS,
225
+ maxBuffer: PODMAN_SETUP_MAX_BUFFER,
226
+ });
227
+ }
228
+ catch (err) {
229
+ const detail = commandErrorDetail(err);
230
+ return {
231
+ ok: false,
232
+ initialized: true,
233
+ error: [
234
+ 'Podman setup failed during UI session launch.',
235
+ detail || 'Unknown Podman error.',
236
+ '',
237
+ 'Try in a terminal:',
238
+ ' podman machine init',
239
+ ' podman machine start',
240
+ ].join('\n'),
241
+ };
242
+ }
243
+ const after = (0, runtime_js_1.checkRuntime)(preferred);
244
+ if (!after.ok) {
245
+ return {
246
+ ok: false,
247
+ initialized: true,
248
+ error: after.error || 'Container runtime unavailable after setup.',
249
+ };
250
+ }
251
+ return { ok: true, initialized: true };
252
+ }
253
+ function createWebTerminalInitId() {
254
+ return `wti-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
255
+ }
256
+ function createWebTerminalInitJob(agent, workdir) {
257
+ const now = new Date().toISOString();
258
+ return {
259
+ id: createWebTerminalInitId(),
260
+ agent,
261
+ workdir,
262
+ status: 'running',
263
+ stage: 'queued',
264
+ message: 'Queued session initialization.',
265
+ startedAt: now,
266
+ updatedAt: now,
267
+ session: null,
268
+ error: null,
269
+ code: null,
270
+ phase: null,
271
+ initialized: false,
272
+ };
273
+ }
274
+ function pruneWebTerminalInitJobs() {
275
+ const now = Date.now();
276
+ for (const [id, job] of webTerminalInitJobs.entries()) {
277
+ if (job.status === 'running')
278
+ continue;
279
+ const ageMs = now - Date.parse(job.updatedAt || job.startedAt || '');
280
+ if (Number.isFinite(ageMs) && ageMs > WEB_TERMINAL_INIT_RETENTION_MS) {
281
+ webTerminalInitJobs.delete(id);
282
+ }
283
+ }
284
+ }
285
+ function updateWebTerminalInitJob(id, patch) {
286
+ const existing = webTerminalInitJobs.get(id);
287
+ if (!existing)
288
+ return null;
289
+ const updated = {
290
+ ...existing,
291
+ ...patch,
292
+ updatedAt: new Date().toISOString(),
293
+ };
294
+ webTerminalInitJobs.set(id, updated);
295
+ return updated;
296
+ }
297
+ function serializeWebTerminalInitJob(job) {
298
+ return {
299
+ id: job.id,
300
+ agent: job.agent,
301
+ workdir: job.workdir,
302
+ status: job.status,
303
+ stage: job.stage,
304
+ message: job.message,
305
+ startedAt: job.startedAt,
306
+ updatedAt: job.updatedAt,
307
+ session: job.session,
308
+ error: job.error,
309
+ code: job.code,
310
+ phase: job.phase,
311
+ initialized: job.initialized,
312
+ };
313
+ }
314
+ async function withWebTerminalImagePullLock(lockKey, work) {
315
+ const existing = webTerminalImagePullLocks.get(lockKey);
316
+ if (existing) {
317
+ await existing;
318
+ return;
319
+ }
320
+ let current = null;
321
+ current = (async () => {
322
+ try {
323
+ await work();
324
+ }
325
+ finally {
326
+ if (current && webTerminalImagePullLocks.get(lockKey) === current) {
327
+ webTerminalImagePullLocks.delete(lockKey);
328
+ }
329
+ }
330
+ })();
331
+ webTerminalImagePullLocks.set(lockKey, current);
332
+ await current;
333
+ }
334
+ async function ensureWebTerminalImageReady(runtime, image, onProgress) {
335
+ onProgress?.('image_check', `Checking image availability for ${image}...`);
336
+ if (runtime === 'podman') {
337
+ const imageExists = async () => {
338
+ try {
339
+ await execFileAsync('podman', ['image', 'exists', image], {
340
+ timeout: 10_000,
341
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
342
+ });
343
+ return true;
344
+ }
345
+ catch {
346
+ return false;
347
+ }
348
+ };
349
+ if (await imageExists())
350
+ return;
351
+ await withWebTerminalImagePullLock(`podman:${image}`, async () => {
352
+ if (await imageExists())
353
+ return;
354
+ onProgress?.('image_pull', `Pulling container image ${image}...`);
355
+ await execFileAsync('podman', ['pull', image], {
356
+ timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
357
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
358
+ });
359
+ });
360
+ return;
361
+ }
362
+ const imagesDir = (0, config_js_1.getImagesDir)();
363
+ const sifPath = (0, path_1.join)(imagesDir, (0, container_js_1.imageToSifName)(image));
364
+ if ((0, fs_1.existsSync)(sifPath))
365
+ return;
366
+ await withWebTerminalImagePullLock(`apptainer:${image}`, async () => {
367
+ if ((0, fs_1.existsSync)(sifPath))
368
+ return;
369
+ (0, fs_1.mkdirSync)(imagesDir, { recursive: true });
370
+ onProgress?.('image_pull', `Pulling container image ${image}...`);
371
+ await execFileAsync('apptainer', ['pull', sifPath, `docker://${image}`], {
372
+ timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
373
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
374
+ });
375
+ });
376
+ }
377
+ function getWebTerminalAgentBootstrapSpec(agent) {
378
+ if (agent === 'codex') {
379
+ return { bin: 'codex', pkg: '@openai/codex' };
380
+ }
381
+ return { bin: 'claude', pkg: '@anthropic-ai/claude-code' };
382
+ }
383
+ async function withWebTerminalAgentPrepareLock(lockKey, work) {
384
+ const existing = webTerminalAgentPrepLocks.get(lockKey);
385
+ if (existing) {
386
+ await existing;
387
+ return;
388
+ }
389
+ let current = null;
390
+ current = (async () => {
391
+ try {
392
+ await work();
393
+ }
394
+ finally {
395
+ if (current && webTerminalAgentPrepLocks.get(lockKey) === current) {
396
+ webTerminalAgentPrepLocks.delete(lockKey);
397
+ }
398
+ }
399
+ })();
400
+ webTerminalAgentPrepLocks.set(lockKey, current);
401
+ await current;
402
+ }
403
+ function getPodmanPrewarmNetworkArgs(networkMode) {
404
+ const mode = String(networkMode || '').trim().toLowerCase();
405
+ if (mode === 'none')
406
+ return ['--network', 'none'];
407
+ if (mode === 'host')
408
+ return ['--network', 'host'];
409
+ return [];
410
+ }
411
+ async function runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script) {
412
+ if (runtime === 'podman') {
413
+ const result = await execFileAsync('podman', [
414
+ 'run',
415
+ '--rm',
416
+ '--workdir', '/work',
417
+ '--volume', `${sandboxHome}:/home/sandbox`,
418
+ '--volume', `${resolvedWorkdir}:/work`,
419
+ '--env', 'HOME=/home/sandbox',
420
+ ...getPodmanPrewarmNetworkArgs(networkMode),
421
+ image,
422
+ 'bash',
423
+ '-lc',
424
+ script,
425
+ ], {
426
+ timeout: WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS,
427
+ maxBuffer: WEB_TERMINAL_AGENT_PREP_MAX_BUFFER,
428
+ });
429
+ return {
430
+ stdout: String(result?.stdout || ''),
431
+ stderr: String(result?.stderr || ''),
432
+ };
433
+ }
434
+ const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image));
435
+ const result = await execFileAsync('apptainer', [
436
+ 'exec',
437
+ '--containall',
438
+ '--cleanenv',
439
+ '--home', `${sandboxHome}:/home/sandbox`,
440
+ '--bind', `${resolvedWorkdir}:/work`,
441
+ '--pwd', '/work',
442
+ sifPath,
443
+ 'bash',
444
+ '-lc',
445
+ script,
446
+ ], {
447
+ timeout: WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS,
448
+ maxBuffer: WEB_TERMINAL_AGENT_PREP_MAX_BUFFER,
449
+ });
450
+ return {
451
+ stdout: String(result?.stdout || ''),
452
+ stderr: String(result?.stderr || ''),
453
+ };
454
+ }
455
+ async function ensureWebTerminalAgentReady(runtime, image, agent, resolvedWorkdir, networkMode, onProgress) {
456
+ const spec = getWebTerminalAgentBootstrapSpec(agent);
457
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
458
+ const installedBin = (0, path_1.join)(sandboxHome, '.npm-global', 'bin', spec.bin);
459
+ if ((0, fs_1.existsSync)(installedBin)) {
460
+ return;
461
+ }
462
+ await withWebTerminalAgentPrepareLock(spec.bin, async () => {
463
+ if ((0, fs_1.existsSync)(installedBin))
464
+ return;
465
+ onProgress?.('agent_prepare', `Preparing ${agent} CLI in sandbox home...`);
466
+ const script = [
467
+ 'set -euo pipefail',
468
+ 'export HOME=/home/sandbox',
469
+ 'mkdir -p "$HOME/.npm-global"',
470
+ 'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
471
+ 'export PATH="$HOME/.npm-global/bin:$PATH"',
472
+ `if ! command -v ${spec.bin} >/dev/null 2>&1; then npm i -g ${spec.pkg}; fi`,
473
+ ].join('\n');
474
+ await runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script);
475
+ });
476
+ }
477
+ function extractAgentVersionFromOutput(output) {
478
+ const marker = String(output || '')
479
+ .split('\n')
480
+ .map((line) => line.trim())
481
+ .find((line) => line.startsWith('LABGATE_AGENT_VERSION:'));
482
+ if (!marker)
483
+ return null;
484
+ const raw = marker.slice('LABGATE_AGENT_VERSION:'.length).trim();
485
+ if (!raw)
486
+ return null;
487
+ return raw.replace(/^v/, '');
488
+ }
489
+ async function updateWebTerminalAgentCli(runtime, image, agent, resolvedWorkdir, networkMode) {
490
+ const spec = getWebTerminalAgentBootstrapSpec(agent);
491
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
492
+ let version = null;
493
+ await withWebTerminalAgentPrepareLock(spec.bin, async () => {
494
+ const script = [
495
+ 'set -euo pipefail',
496
+ 'export HOME=/home/sandbox',
497
+ 'mkdir -p "$HOME/.npm-global"',
498
+ 'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
499
+ 'export PATH="$HOME/.npm-global/bin:$PATH"',
500
+ `npm i -g "${spec.pkg}"`,
501
+ `_labgate_ver="$(${spec.bin} --version 2>/dev/null || true)"`,
502
+ '_labgate_ver="$(printf "%s" "$_labgate_ver" | head -n 1 | tr -d \'\\r\')"',
503
+ 'echo "LABGATE_AGENT_VERSION:${_labgate_ver}"',
504
+ ].join('\n');
505
+ const result = await runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script);
506
+ version = extractAgentVersionFromOutput([result.stdout, result.stderr].filter(Boolean).join('\n'));
507
+ });
508
+ return { version };
509
+ }
510
+ function toRuntimeUnavailableResult(runtimeReady) {
511
+ return {
512
+ ok: false,
513
+ status: runtimeReady.initialized ? 502 : 503,
514
+ body: {
515
+ ok: false,
516
+ code: 'runtime_unavailable',
517
+ phase: 'runtime_setup',
518
+ initialized: runtimeReady.initialized,
519
+ error: runtimeReady.error || 'Container runtime unavailable.',
520
+ },
521
+ };
522
+ }
523
+ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
524
+ const onProgress = opts.onProgress;
525
+ const config = (0, config_js_1.loadConfig)();
526
+ onProgress?.('runtime_setup', 'Checking container runtime...');
527
+ const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
528
+ if (!runtimeReady.ok) {
529
+ return toRuntimeUnavailableResult(runtimeReady);
530
+ }
531
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
532
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
533
+ return {
534
+ ok: false,
535
+ status: runtimeReady.initialized ? 502 : 503,
536
+ body: {
537
+ ok: false,
538
+ code: 'runtime_unavailable',
539
+ phase: 'runtime_setup',
540
+ initialized: runtimeReady.initialized,
541
+ error: runtimeCheck.error || 'Container runtime unavailable.',
542
+ },
543
+ };
544
+ }
545
+ // Preflight tmux before any slow image/agent preparation to avoid unnecessary side effects.
546
+ const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
547
+ if (!tmuxAvailable.ok) {
548
+ return {
549
+ ok: false,
550
+ status: 500,
551
+ body: { ok: false, error: tmuxAvailable.error },
552
+ };
553
+ }
554
+ if (opts.prewarmImage) {
555
+ try {
556
+ await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, onProgress);
557
+ }
558
+ catch (err) {
559
+ const detail = commandErrorDetail(err);
560
+ return {
561
+ ok: false,
562
+ status: 502,
563
+ body: {
564
+ ok: false,
565
+ code: 'image_prepare_failed',
566
+ phase: 'image_prepare',
567
+ runtime: runtimeCheck.runtime,
568
+ image: config.image,
569
+ error: detail || `Failed to pull image ${config.image}.`,
570
+ },
571
+ };
572
+ }
573
+ }
574
+ if (opts.prewarmAgent) {
575
+ try {
576
+ await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, agent, resolvedWorkdir, config.network.mode, onProgress);
577
+ }
578
+ catch (err) {
579
+ const detail = commandErrorDetail(err);
580
+ return {
581
+ ok: false,
582
+ status: 502,
583
+ body: {
584
+ ok: false,
585
+ code: 'agent_prepare_failed',
586
+ phase: 'agent_prepare',
587
+ runtime: runtimeCheck.runtime,
588
+ agent,
589
+ image: config.image,
590
+ error: detail || `Failed to prepare ${agent} in sandbox home.`,
591
+ },
592
+ };
593
+ }
594
+ }
595
+ onProgress?.('tmux_check', 'Checking terminal multiplexer availability...');
596
+ const cliEntrypoint = resolveCliEntrypoint();
597
+ const id = `wt-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
598
+ const record = (0, web_terminal_js_1.createWebTerminalRecord)({
599
+ id,
600
+ agent,
601
+ runtime: runtimeCheck.runtime,
602
+ workdir: resolvedWorkdir,
603
+ });
604
+ (0, web_terminal_js_1.writeWebTerminalRecord)(record);
605
+ onProgress?.('session_start', `Starting ${agent} terminal session...`);
606
+ try {
607
+ await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint);
608
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
609
+ }
610
+ catch (err) {
611
+ const message = err?.message ?? String(err);
612
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
613
+ return {
614
+ ok: false,
615
+ status: 500,
616
+ body: { ok: false, error: `Could not start tmux session: ${message}` },
617
+ };
618
+ }
619
+ onProgress?.('session_start', 'Attaching terminal bridge...');
620
+ const bridge = await ensureWebTerminalBridge(record);
621
+ if (!bridge) {
622
+ try {
623
+ await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
624
+ }
625
+ catch { /* best effort */ }
626
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, {
627
+ status: 'failed',
628
+ exitCode: 1,
629
+ error: 'node-pty bridge unavailable',
630
+ });
631
+ return {
632
+ ok: false,
633
+ status: 500,
634
+ body: {
635
+ ok: false,
636
+ error: 'Started tmux session but could not create terminal bridge (node-pty unavailable).',
637
+ },
638
+ };
639
+ }
640
+ await waitForWebTerminalStartupSummary(record, bridge, onProgress);
641
+ return {
642
+ ok: true,
643
+ session: serializeWebTerminalSession(record),
644
+ };
645
+ }
140
646
  function readBody(req) {
141
647
  return new Promise((resolve, reject) => {
142
648
  const chunks = [];
@@ -420,7 +926,9 @@ function normalizeWebTerminalAgent(raw) {
420
926
  function serializeWebTerminalSession(record) {
421
927
  return {
422
928
  id: record.id,
929
+ name: record.name || '',
423
930
  agent: record.agent,
931
+ runtime: record.runtime || '',
424
932
  workdir: record.workdir,
425
933
  node: record.node,
426
934
  tmuxSession: record.tmuxSession,
@@ -441,11 +949,62 @@ async function loadNodePtyModule() {
441
949
  }
442
950
  }
443
951
  function appendWebTerminalBuffer(bridge, chunk) {
952
+ if (!chunk)
953
+ return [];
444
954
  bridge.buffer += chunk;
445
955
  // Keep recent output bounded to avoid unbounded memory growth.
446
- if (bridge.buffer.length > 512_000) {
447
- bridge.buffer = bridge.buffer.slice(bridge.buffer.length - 512_000);
956
+ if (bridge.buffer.length > WEB_TERMINAL_BUFFER_MAX_BYTES) {
957
+ bridge.buffer = bridge.buffer.slice(bridge.buffer.length - WEB_TERMINAL_BUFFER_MAX_BYTES);
958
+ }
959
+ const appended = [];
960
+ for (let i = 0; i < chunk.length; i += WEB_TERMINAL_HISTORY_CHUNK_BYTES) {
961
+ const piece = chunk.slice(i, i + WEB_TERMINAL_HISTORY_CHUNK_BYTES);
962
+ if (!piece)
963
+ continue;
964
+ const seq = bridge.nextSeq++;
965
+ const nextChunk = { seq, data: piece };
966
+ bridge.history.push(nextChunk);
967
+ appended.push(nextChunk);
968
+ bridge.historyBytes += piece.length;
969
+ }
970
+ while (bridge.historyBytes > WEB_TERMINAL_HISTORY_MAX_BYTES && bridge.history.length > 0) {
971
+ const removed = bridge.history.shift();
972
+ if (!removed)
973
+ break;
974
+ bridge.historyBytes = Math.max(0, bridge.historyBytes - removed.data.length);
448
975
  }
976
+ return appended;
977
+ }
978
+ function getWebTerminalHistoryPage(bridge, options) {
979
+ const history = bridge.history;
980
+ if (!history.length) {
981
+ return {
982
+ chunks: [],
983
+ hasMore: false,
984
+ nextBefore: null,
985
+ oldestSeq: null,
986
+ latestSeq: null,
987
+ };
988
+ }
989
+ const beforeSeq = options?.beforeSeq ?? null;
990
+ const rawLimit = options?.limit ?? WEB_TERMINAL_HISTORY_PAGE_DEFAULT;
991
+ const limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(rawLimit)));
992
+ let endIndex = history.length;
993
+ if (beforeSeq !== null && Number.isFinite(beforeSeq)) {
994
+ const idx = history.findIndex((chunk) => chunk.seq >= beforeSeq);
995
+ endIndex = idx >= 0 ? idx : history.length;
996
+ }
997
+ endIndex = Math.max(0, Math.min(endIndex, history.length));
998
+ const startIndex = Math.max(0, endIndex - limit);
999
+ const chunks = history.slice(startIndex, endIndex);
1000
+ const hasMore = startIndex > 0;
1001
+ return {
1002
+ chunks,
1003
+ hasMore,
1004
+ nextBefore: hasMore && chunks.length > 0 ? chunks[0].seq : null,
1005
+ oldestSeq: history[0]?.seq ?? null,
1006
+ latestSeq: history[history.length - 1]?.seq ?? null,
1007
+ };
449
1008
  }
450
1009
  function sendWebTerminalMessage(ws, payload) {
451
1010
  if (ws.readyState !== ws_1.WebSocket.OPEN)
@@ -457,6 +1016,157 @@ function sendWebTerminalMessage(ws, payload) {
457
1016
  // Best effort.
458
1017
  }
459
1018
  }
1019
+ function parseJsonObjectLine(line) {
1020
+ try {
1021
+ const parsed = JSON.parse(line);
1022
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1023
+ return null;
1024
+ return parsed;
1025
+ }
1026
+ catch {
1027
+ return null;
1028
+ }
1029
+ }
1030
+ function readRecordString(record, key) {
1031
+ const value = record[key];
1032
+ return typeof value === 'string' ? value : '';
1033
+ }
1034
+ function normalizeToolUseId(value) {
1035
+ if (typeof value === 'string')
1036
+ return value.trim();
1037
+ if (typeof value === 'number' && Number.isFinite(value))
1038
+ return String(value);
1039
+ return '';
1040
+ }
1041
+ function collectClaudeTextFromContent(content) {
1042
+ if (!Array.isArray(content))
1043
+ return '';
1044
+ let text = '';
1045
+ for (const part of content) {
1046
+ if (!part || typeof part !== 'object' || Array.isArray(part))
1047
+ continue;
1048
+ const node = part;
1049
+ if (node.type === 'text' && typeof node.text === 'string') {
1050
+ text += node.text;
1051
+ }
1052
+ }
1053
+ return text;
1054
+ }
1055
+ function extractClaudeStreamSessionId(event) {
1056
+ const direct = readRecordString(event, 'session_id').trim();
1057
+ if (direct)
1058
+ return direct;
1059
+ const message = event.message;
1060
+ if (!message || typeof message !== 'object' || Array.isArray(message))
1061
+ return '';
1062
+ const nested = readRecordString(message, 'session_id').trim();
1063
+ return nested || '';
1064
+ }
1065
+ function extractClaudeAssistantSnapshot(event) {
1066
+ const type = readRecordString(event, 'type').trim().toLowerCase();
1067
+ if (type === 'assistant') {
1068
+ const message = event.message;
1069
+ if (message && typeof message === 'object' && !Array.isArray(message)) {
1070
+ const content = message.content;
1071
+ const contentText = collectClaudeTextFromContent(content);
1072
+ if (contentText)
1073
+ return contentText;
1074
+ }
1075
+ }
1076
+ if (type === 'result') {
1077
+ const resultText = readRecordString(event, 'result');
1078
+ if (resultText)
1079
+ return resultText;
1080
+ }
1081
+ return '';
1082
+ }
1083
+ function isClaudeAuthenticationFailure(event, assistantSnapshot, stderrText) {
1084
+ const packed = JSON.stringify(event).toLowerCase();
1085
+ const message = `${assistantSnapshot}\n${stderrText}`.toLowerCase();
1086
+ const authRe = /oauth token has expired|authentication_error|failed to authenticate|api error:\s*401/i;
1087
+ return authRe.test(packed) || authRe.test(message);
1088
+ }
1089
+ function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSessionId) {
1090
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
1091
+ const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
1092
+ const resume = resumeSessionId.trim();
1093
+ // Ensure display.json exists before bind-mounting it
1094
+ const displayDbPath = (0, config_js_1.getDisplayDbPath)();
1095
+ if (!(0, fs_1.existsSync)(displayDbPath)) {
1096
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(displayDbPath));
1097
+ (0, fs_1.writeFileSync)(displayDbPath, JSON.stringify({ version: 1, events: [] }, null, 2) + '\n', {
1098
+ encoding: 'utf-8',
1099
+ mode: config_js_1.PRIVATE_FILE_MODE,
1100
+ });
1101
+ }
1102
+ return [
1103
+ 'exec',
1104
+ '--containall',
1105
+ '--cleanenv',
1106
+ '--home', `${sandboxHome}:/home/sandbox`,
1107
+ '--bind', `${workdir}:/work`,
1108
+ '--pwd', '/work',
1109
+ ...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
1110
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1111
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1112
+ const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
1113
+ return ['--bind', bindSpec];
1114
+ }),
1115
+ ...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
1116
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1117
+ const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
1118
+ return ['--bind', bindSpec];
1119
+ }),
1120
+ ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1121
+ '--bind', `${(0, config_js_1.getDisplayDbPath)()}:/labgate-config/display.json`,
1122
+ '--env', 'HOME=/home/sandbox',
1123
+ '--env', 'ANTHROPIC_API_KEY=',
1124
+ sifPath,
1125
+ '/home/sandbox/.npm-global/bin/claude',
1126
+ '-p',
1127
+ '--verbose',
1128
+ '--output-format',
1129
+ 'stream-json',
1130
+ '--include-partial-messages',
1131
+ ...(resume ? ['--resume', resume] : []),
1132
+ prompt,
1133
+ ];
1134
+ }
1135
+ function sleep(ms) {
1136
+ return new Promise((resolve) => setTimeout(resolve, ms));
1137
+ }
1138
+ function stripAnsiForStartupReadiness(text) {
1139
+ return String(text || '')
1140
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
1141
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
1142
+ .replace(/\r/g, '\n');
1143
+ }
1144
+ function hasWebTerminalStartupSummary(buffer) {
1145
+ const plain = stripAnsiForStartupReadiness(buffer);
1146
+ return WEB_TERMINAL_STARTUP_HEADER_RE.test(plain) && WEB_TERMINAL_STARTUP_BLOCKED_RE.test(plain);
1147
+ }
1148
+ async function waitForWebTerminalStartupSummary(record, bridge, onProgress) {
1149
+ const deadline = Date.now() + WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS;
1150
+ let lastAliveCheck = 0;
1151
+ onProgress?.('session_start', `Finalizing ${record.agent} startup...`);
1152
+ while (Date.now() < deadline) {
1153
+ const recent = bridge.buffer.length > WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT
1154
+ ? bridge.buffer.slice(-WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT)
1155
+ : bridge.buffer;
1156
+ if (hasWebTerminalStartupSummary(recent))
1157
+ return;
1158
+ if (!bridge.pty)
1159
+ return;
1160
+ const now = Date.now();
1161
+ if (now - lastAliveCheck >= WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS) {
1162
+ lastAliveCheck = now;
1163
+ const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
1164
+ if (!alive)
1165
+ return;
1166
+ }
1167
+ await sleep(WEB_TERMINAL_STARTUP_READY_POLL_MS);
1168
+ }
1169
+ }
460
1170
  function broadcastWebTerminalMessage(bridge, payload) {
461
1171
  for (const ws of bridge.clients) {
462
1172
  if (ws.readyState !== ws_1.WebSocket.OPEN) {
@@ -466,6 +1176,23 @@ function broadcastWebTerminalMessage(bridge, payload) {
466
1176
  sendWebTerminalMessage(ws, payload);
467
1177
  }
468
1178
  }
1179
+ function closeWebTerminalBridgeClients(bridge, code = 4001, reason = 'labgate-bridge-detached') {
1180
+ const clients = Array.from(bridge.clients);
1181
+ bridge.clients.clear();
1182
+ for (const ws of clients) {
1183
+ try {
1184
+ ws.close(code, reason);
1185
+ }
1186
+ catch {
1187
+ try {
1188
+ ws.close();
1189
+ }
1190
+ catch {
1191
+ // Best effort.
1192
+ }
1193
+ }
1194
+ }
1195
+ }
469
1196
  async function ensureWebTerminalBridge(record) {
470
1197
  const existing = webTerminalBridges.get(record.id);
471
1198
  if (existing && existing.pty)
@@ -482,6 +1209,13 @@ async function ensureWebTerminalBridge(record) {
482
1209
  log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
483
1210
  return null;
484
1211
  }
1212
+ try {
1213
+ // Keep wheel scrolling intuitive for both new and existing sessions.
1214
+ await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, 'mouse', 'on'], { timeout: 10_000 });
1215
+ }
1216
+ catch {
1217
+ // Best effort only; attach should still proceed.
1218
+ }
485
1219
  const env = {};
486
1220
  for (const [k, v] of Object.entries(process.env)) {
487
1221
  if (v !== undefined)
@@ -514,40 +1248,65 @@ async function ensureWebTerminalBridge(record) {
514
1248
  const bridge = existing || {
515
1249
  id: record.id,
516
1250
  buffer: '',
1251
+ history: [],
1252
+ historyBytes: 0,
1253
+ nextSeq: 1,
1254
+ stopRequested: false,
517
1255
  clients: new Set(),
518
1256
  pty: ptyProcess,
519
1257
  };
1258
+ bridge.stopRequested = false;
520
1259
  bridge.pty = ptyProcess;
521
1260
  webTerminalBridges.set(record.id, bridge);
522
1261
  ptyProcess.onData((data) => {
523
- appendWebTerminalBuffer(bridge, data);
524
- broadcastWebTerminalMessage(bridge, { type: 'data', id: record.id, data });
1262
+ const appended = appendWebTerminalBuffer(bridge, data);
1263
+ for (const chunk of appended) {
1264
+ broadcastWebTerminalMessage(bridge, {
1265
+ type: 'data',
1266
+ id: record.id,
1267
+ data: chunk.data,
1268
+ seqStart: chunk.seq,
1269
+ seqEnd: chunk.seq,
1270
+ });
1271
+ }
525
1272
  });
526
1273
  ptyProcess.onExit(async () => {
1274
+ const stopRequested = bridge.stopRequested;
1275
+ bridge.stopRequested = false;
527
1276
  bridge.pty = null;
528
1277
  const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
529
- if (!alive) {
530
- const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
531
- const finalCode = exitInfo?.exitCode ?? (record.exitCode ?? 0);
532
- const finalStatus = finalCode === 0 ? 'exited' : 'failed';
533
- const updated = (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, {
534
- status: finalStatus,
535
- exitCode: finalCode,
536
- error: finalCode === 0 ? null : (record.error || `Exited with code ${finalCode}`),
537
- });
538
- broadcastWebTerminalMessage(bridge, {
539
- type: 'status',
540
- id: record.id,
541
- status: updated?.status ?? finalStatus,
542
- exitCode: updated?.exitCode ?? finalCode,
543
- });
1278
+ if (alive) {
1279
+ if (stopRequested)
1280
+ return;
1281
+ // Another tmux client (for example `labgate continue`) may have force-detached this bridge.
1282
+ // Clear stale alternate-screen data and require clients to reconnect for a clean reattach.
1283
+ bridge.buffer = '';
1284
+ bridge.history = [];
1285
+ bridge.historyBytes = 0;
1286
+ closeWebTerminalBridgeClients(bridge);
1287
+ return;
544
1288
  }
1289
+ const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
1290
+ const finalCode = exitInfo?.exitCode ?? (record.exitCode ?? 0);
1291
+ const finalStatus = finalCode === 0 ? 'exited' : 'failed';
1292
+ const updated = (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, {
1293
+ status: finalStatus,
1294
+ exitCode: finalCode,
1295
+ error: finalCode === 0 ? null : (record.error || `Exited with code ${finalCode}`),
1296
+ });
1297
+ broadcastWebTerminalMessage(bridge, {
1298
+ type: 'status',
1299
+ id: record.id,
1300
+ status: updated?.status ?? finalStatus,
1301
+ exitCode: updated?.exitCode ?? finalCode,
1302
+ });
545
1303
  });
546
1304
  return bridge;
547
1305
  }
548
1306
  function stopWebTerminalBridge(bridge) {
549
1307
  if (!bridge.pty)
550
1308
  return;
1309
+ bridge.stopRequested = true;
551
1310
  try {
552
1311
  bridge.pty.kill('SIGTERM');
553
1312
  }
@@ -555,17 +1314,251 @@ function stopWebTerminalBridge(bridge) {
555
1314
  // Best effort.
556
1315
  }
557
1316
  }
558
- function serveHTML(res) {
1317
+ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1318
+ const send = (payload) => {
1319
+ sendWebTerminalMessage(ws, payload);
1320
+ };
1321
+ const trimmedPrompt = prompt.trim();
1322
+ if (!trimmedPrompt) {
1323
+ send({ type: 'error', error: 'prompt is required' });
1324
+ return () => { };
1325
+ }
1326
+ send({ type: 'status', stage: 'runtime_setup', message: 'Checking container runtime...' });
1327
+ const config = (0, config_js_1.loadConfig)();
1328
+ const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
1329
+ if (!runtimeReady.ok) {
1330
+ send({
1331
+ type: 'error',
1332
+ code: 'runtime_unavailable',
1333
+ error: runtimeReady.error || 'Container runtime unavailable.',
1334
+ });
1335
+ return () => { };
1336
+ }
1337
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
1338
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
1339
+ send({
1340
+ type: 'error',
1341
+ code: 'runtime_unavailable',
1342
+ error: runtimeCheck.error || 'Container runtime unavailable.',
1343
+ });
1344
+ return () => { };
1345
+ }
1346
+ if (runtimeCheck.runtime !== 'apptainer') {
1347
+ send({
1348
+ type: 'error',
1349
+ code: 'runtime_unsupported',
1350
+ error: `Headless Claude chat currently supports Apptainer only (detected: ${runtimeCheck.runtime}).`,
1351
+ });
1352
+ return () => { };
1353
+ }
559
1354
  try {
560
- const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8').replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN);
561
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
562
- res.end(html);
1355
+ await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, (stage, message) => {
1356
+ send({ type: 'status', stage, message });
1357
+ });
563
1358
  }
564
- catch {
565
- res.writeHead(500, { 'Content-Type': 'text/plain' });
566
- res.end('Could not load UI HTML');
1359
+ catch (err) {
1360
+ send({
1361
+ type: 'error',
1362
+ code: 'image_prepare_failed',
1363
+ error: commandErrorDetail(err) || `Failed to prepare image ${config.image}.`,
1364
+ });
1365
+ return () => { };
567
1366
  }
568
- }
1367
+ try {
1368
+ await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, 'claude', record.workdir, config.network.mode, (stage, message) => send({ type: 'status', stage, message }));
1369
+ }
1370
+ catch (err) {
1371
+ send({
1372
+ type: 'error',
1373
+ code: 'agent_prepare_failed',
1374
+ error: commandErrorDetail(err) || 'Failed to prepare Claude CLI in sandbox home.',
1375
+ });
1376
+ return () => { };
1377
+ }
1378
+ const args = buildClaudeHeadlessApptainerArgs(config, record.workdir, trimmedPrompt, resumeSessionId);
1379
+ send({ type: 'status', stage: 'run', message: 'Running Claude in headless mode...' });
1380
+ const child = (0, child_process_1.spawn)('apptainer', args, {
1381
+ cwd: record.workdir,
1382
+ env: process.env,
1383
+ stdio: ['ignore', 'pipe', 'pipe'],
1384
+ });
1385
+ let stdoutBuffer = '';
1386
+ let stderrBuffer = '';
1387
+ let latestClaudeSessionId = resumeSessionId.trim();
1388
+ let emittedAssistantText = '';
1389
+ let doneSent = false;
1390
+ let syntheticToolUseSeq = 0;
1391
+ const sendDone = (exitCode) => {
1392
+ if (doneSent)
1393
+ return;
1394
+ doneSent = true;
1395
+ send({
1396
+ type: 'done',
1397
+ exitCode,
1398
+ sessionId: latestClaudeSessionId || null,
1399
+ isError: exitCode !== 0,
1400
+ });
1401
+ try {
1402
+ ws.close();
1403
+ }
1404
+ catch {
1405
+ // Best effort.
1406
+ }
1407
+ };
1408
+ child.stdout.on('data', (chunk) => {
1409
+ stdoutBuffer += chunk.toString('utf-8');
1410
+ while (true) {
1411
+ const idx = stdoutBuffer.indexOf('\n');
1412
+ if (idx < 0)
1413
+ break;
1414
+ const line = stdoutBuffer.slice(0, idx).trim();
1415
+ stdoutBuffer = stdoutBuffer.slice(idx + 1);
1416
+ if (!line)
1417
+ continue;
1418
+ const event = parseJsonObjectLine(line);
1419
+ if (!event)
1420
+ continue;
1421
+ const sessionId = extractClaudeStreamSessionId(event);
1422
+ if (sessionId && sessionId !== latestClaudeSessionId) {
1423
+ latestClaudeSessionId = sessionId;
1424
+ send({ type: 'session', sessionId });
1425
+ }
1426
+ const snapshot = extractClaudeAssistantSnapshot(event);
1427
+ if (snapshot) {
1428
+ let delta = '';
1429
+ if (snapshot.startsWith(emittedAssistantText)) {
1430
+ delta = snapshot.slice(emittedAssistantText.length);
1431
+ emittedAssistantText = snapshot;
1432
+ }
1433
+ else if (!emittedAssistantText.startsWith(snapshot)) {
1434
+ delta = snapshot;
1435
+ emittedAssistantText = snapshot;
1436
+ }
1437
+ if (delta) {
1438
+ send({ type: 'delta', text: delta });
1439
+ }
1440
+ }
1441
+ // Forward tool_use events from assistant messages
1442
+ const eventType = readRecordString(event, 'type').trim().toLowerCase();
1443
+ if (eventType === 'assistant') {
1444
+ const msgContent = event.message?.content;
1445
+ if (Array.isArray(msgContent)) {
1446
+ for (const block of msgContent) {
1447
+ if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_use') {
1448
+ const toolBlock = block;
1449
+ const toolName = String(toolBlock.name || 'tool');
1450
+ const detail = extractToolDetailFromToolUseBlock(toolBlock);
1451
+ const toolUseId = normalizeToolUseId(toolBlock.id) || `tool-${Date.now().toString(36)}-${(++syntheticToolUseSeq).toString(36)}`;
1452
+ // Intercept display_widget calls and forward rich content payload
1453
+ if (toolName === 'display_widget') {
1454
+ const input = toolBlock.input;
1455
+ if (input && typeof input.widget === 'string') {
1456
+ send({
1457
+ type: 'rich_content',
1458
+ widget: String(input.widget),
1459
+ title: input.title ? String(input.title) : undefined,
1460
+ data: (input.data && typeof input.data === 'object') ? input.data : {},
1461
+ id: toolUseId,
1462
+ });
1463
+ }
1464
+ }
1465
+ // Always also send the normal tool_use card
1466
+ send({ type: 'tool_use', tool_use_id: toolUseId, name: toolName, detail });
1467
+ }
1468
+ }
1469
+ }
1470
+ }
1471
+ // Forward tool_result events (indicates tool execution completed)
1472
+ if (eventType === 'user') {
1473
+ const msgContent = event.message?.content;
1474
+ if (Array.isArray(msgContent)) {
1475
+ for (const block of msgContent) {
1476
+ if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_result') {
1477
+ const resultBlock = block;
1478
+ const toolUseId = normalizeToolUseId(resultBlock.tool_use_id);
1479
+ send({
1480
+ type: 'tool_result',
1481
+ tool_use_id: toolUseId || undefined,
1482
+ is_error: !!resultBlock.is_error,
1483
+ });
1484
+ }
1485
+ }
1486
+ }
1487
+ }
1488
+ if (isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
1489
+ send({
1490
+ type: 'auth_required',
1491
+ error: 'Claude authentication is required. Run /login in raw terminal mode to refresh session.',
1492
+ });
1493
+ }
1494
+ }
1495
+ });
1496
+ child.stderr.on('data', (chunk) => {
1497
+ const text = chunk.toString('utf-8');
1498
+ stderrBuffer = (stderrBuffer + text).slice(-CLAUDE_HEADLESS_STDERR_LIMIT);
1499
+ });
1500
+ child.on('error', (err) => {
1501
+ send({
1502
+ type: 'error',
1503
+ code: 'spawn_failed',
1504
+ error: err.message || String(err),
1505
+ });
1506
+ sendDone(1);
1507
+ });
1508
+ child.on('close', (code) => {
1509
+ const remaining = stdoutBuffer.trim();
1510
+ if (remaining) {
1511
+ const event = parseJsonObjectLine(remaining);
1512
+ if (event) {
1513
+ const sessionId = extractClaudeStreamSessionId(event);
1514
+ if (sessionId && sessionId !== latestClaudeSessionId) {
1515
+ latestClaudeSessionId = sessionId;
1516
+ send({ type: 'session', sessionId });
1517
+ }
1518
+ const snapshot = extractClaudeAssistantSnapshot(event);
1519
+ if (snapshot) {
1520
+ if (snapshot.startsWith(emittedAssistantText)) {
1521
+ const delta = snapshot.slice(emittedAssistantText.length);
1522
+ emittedAssistantText = snapshot;
1523
+ if (delta)
1524
+ send({ type: 'delta', text: delta });
1525
+ }
1526
+ else if (!emittedAssistantText.startsWith(snapshot)) {
1527
+ emittedAssistantText = snapshot;
1528
+ send({ type: 'delta', text: snapshot });
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+ const exitCode = typeof code === 'number' ? code : 1;
1534
+ if (exitCode !== 0) {
1535
+ const detail = stderrBuffer.trim();
1536
+ if (detail) {
1537
+ send({ type: 'error', code: 'claude_failed', error: detail });
1538
+ }
1539
+ }
1540
+ sendDone(exitCode);
1541
+ });
1542
+ return () => {
1543
+ try {
1544
+ child.kill('SIGTERM');
1545
+ }
1546
+ catch {
1547
+ // Best effort.
1548
+ }
1549
+ };
1550
+ }
1551
+ function serveHTML(res) {
1552
+ try {
1553
+ const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8').replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN);
1554
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1555
+ res.end(html);
1556
+ }
1557
+ catch {
1558
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
1559
+ res.end('Could not load UI HTML');
1560
+ }
1561
+ }
569
1562
  function handleGetConfig(_req, res) {
570
1563
  const effective = (0, config_js_1.loadEffectiveConfig)();
571
1564
  const response = { ...effective.config };
@@ -1334,6 +2327,39 @@ function findProjectJsonlFiles(agent) {
1334
2327
  /**
1335
2328
  * Extract a human-readable detail string from a JSONL entry's tool_use blocks.
1336
2329
  */
2330
+ function extractToolDetailFromToolUseBlock(block) {
2331
+ const name = String(block.name || '');
2332
+ const inputRaw = block.input;
2333
+ const input = inputRaw && typeof inputRaw === 'object' && !Array.isArray(inputRaw)
2334
+ ? inputRaw
2335
+ : {};
2336
+ if (name === 'Bash' || name === 'bash') {
2337
+ const cmd = String(input.command || '').slice(0, 60);
2338
+ return cmd ? `Ran \`${cmd}\`` : 'Running Bash';
2339
+ }
2340
+ if (name === 'Edit' || name === 'edit') {
2341
+ const file = String(input.file_path || '').split('/').pop() || '';
2342
+ return file ? `Edited ${file}` : 'Editing a file';
2343
+ }
2344
+ if (name === 'Read' || name === 'read') {
2345
+ const file = String(input.file_path || '').split('/').pop() || '';
2346
+ return file ? `Read ${file}` : 'Reading a file';
2347
+ }
2348
+ if (name === 'Write' || name === 'write') {
2349
+ const file = String(input.file_path || '').split('/').pop() || '';
2350
+ return file ? `Wrote ${file}` : 'Writing a file';
2351
+ }
2352
+ if (name === 'Grep' || name === 'grep') {
2353
+ return `Searching for "${String(input.pattern || '').slice(0, 40)}"`;
2354
+ }
2355
+ if (name === 'Glob' || name === 'glob') {
2356
+ return `Finding files: ${String(input.pattern || '').slice(0, 40)}`;
2357
+ }
2358
+ if (name === 'Task' || name === 'task') {
2359
+ return 'Spawned subagent';
2360
+ }
2361
+ return `Using ${name}`;
2362
+ }
1337
2363
  function extractToolDetail(entry) {
1338
2364
  if (!entry.message?.content)
1339
2365
  return '';
@@ -1341,35 +2367,8 @@ function extractToolDetail(entry) {
1341
2367
  if (!Array.isArray(content))
1342
2368
  return '';
1343
2369
  for (const block of content) {
1344
- if (block.type === 'tool_use') {
1345
- const name = block.name || '';
1346
- const input = block.input || {};
1347
- if (name === 'Bash' || name === 'bash') {
1348
- const cmd = (input.command || '').slice(0, 60);
1349
- return cmd ? `Ran \`${cmd}\`` : `Running Bash`;
1350
- }
1351
- if (name === 'Edit' || name === 'edit') {
1352
- const file = (input.file_path || '').split('/').pop() || '';
1353
- return file ? `Edited ${file}` : 'Editing a file';
1354
- }
1355
- if (name === 'Read' || name === 'read') {
1356
- const file = (input.file_path || '').split('/').pop() || '';
1357
- return file ? `Read ${file}` : 'Reading a file';
1358
- }
1359
- if (name === 'Write' || name === 'write') {
1360
- const file = (input.file_path || '').split('/').pop() || '';
1361
- return file ? `Wrote ${file}` : 'Writing a file';
1362
- }
1363
- if (name === 'Grep' || name === 'grep') {
1364
- return `Searching for "${(input.pattern || '').slice(0, 40)}"`;
1365
- }
1366
- if (name === 'Glob' || name === 'glob') {
1367
- return `Finding files: ${(input.pattern || '').slice(0, 40)}`;
1368
- }
1369
- if (name === 'Task' || name === 'task') {
1370
- return `Spawned subagent`;
1371
- }
1372
- return `Using ${name}`;
2370
+ if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_use') {
2371
+ return extractToolDetailFromToolUseBlock(block);
1373
2372
  }
1374
2373
  }
1375
2374
  return '';
@@ -1915,55 +2914,209 @@ async function handlePostWebTerminalStart(req, res) {
1915
2914
  json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
1916
2915
  return;
1917
2916
  }
1918
- const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
1919
- if (!tmuxAvailable.ok) {
1920
- json(res, { ok: false, error: tmuxAvailable.error }, 500);
2917
+ const result = await startWebTerminalSession(agent, resolvedWorkdir);
2918
+ if (!result.ok) {
2919
+ json(res, result.body, result.status);
2920
+ return;
2921
+ }
2922
+ json(res, { ok: true, session: result.session });
2923
+ }
2924
+ catch (err) {
2925
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
2926
+ }
2927
+ }
2928
+ async function runWebTerminalInitJob(id) {
2929
+ const job = webTerminalInitJobs.get(id);
2930
+ if (!job)
2931
+ return;
2932
+ const progress = (stage, message) => {
2933
+ updateWebTerminalInitJob(id, {
2934
+ status: 'running',
2935
+ stage,
2936
+ message,
2937
+ });
2938
+ };
2939
+ try {
2940
+ const result = await startWebTerminalSession(job.agent, job.workdir, {
2941
+ prewarmImage: true,
2942
+ prewarmAgent: true,
2943
+ onProgress: progress,
2944
+ });
2945
+ if (result.ok) {
2946
+ updateWebTerminalInitJob(id, {
2947
+ status: 'ready',
2948
+ stage: 'ready',
2949
+ message: 'Session ready.',
2950
+ session: result.session,
2951
+ error: null,
2952
+ code: null,
2953
+ phase: null,
2954
+ });
2955
+ pruneWebTerminalInitJobs();
1921
2956
  return;
1922
2957
  }
1923
- const cliEntrypoint = resolveCliEntrypoint();
1924
- const id = `wt-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
1925
- const record = (0, web_terminal_js_1.createWebTerminalRecord)({
1926
- id,
1927
- agent,
1928
- workdir: resolvedWorkdir,
2958
+ const body = result.body || {};
2959
+ const errText = String(body.error || 'Session initialization failed.');
2960
+ updateWebTerminalInitJob(id, {
2961
+ status: 'failed',
2962
+ stage: 'failed',
2963
+ message: errText.split('\n')[0] || 'Session initialization failed.',
2964
+ session: null,
2965
+ error: errText,
2966
+ code: typeof body.code === 'string' ? body.code : null,
2967
+ phase: typeof body.phase === 'string' ? body.phase : null,
2968
+ initialized: body.initialized === true,
2969
+ });
2970
+ }
2971
+ catch (err) {
2972
+ const detail = commandErrorDetail(err) || (err?.message ?? String(err));
2973
+ updateWebTerminalInitJob(id, {
2974
+ status: 'failed',
2975
+ stage: 'failed',
2976
+ message: 'Session initialization failed.',
2977
+ session: null,
2978
+ error: detail,
2979
+ code: 'init_failed',
2980
+ phase: 'init',
2981
+ initialized: false,
1929
2982
  });
1930
- (0, web_terminal_js_1.writeWebTerminalRecord)(record);
2983
+ }
2984
+ finally {
2985
+ pruneWebTerminalInitJobs();
2986
+ }
2987
+ }
2988
+ async function handlePostWebTerminalInit(req, res) {
2989
+ try {
2990
+ const body = await readBody(req);
2991
+ const parsed = JSON.parse(body || '{}');
2992
+ const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
2993
+ const rawWorkdir = String(parsed.workdir || '').trim();
2994
+ if (!agent) {
2995
+ json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
2996
+ return;
2997
+ }
2998
+ if (!rawWorkdir) {
2999
+ json(res, { ok: false, error: 'workdir is required' }, 400);
3000
+ return;
3001
+ }
3002
+ const resolvedWorkdir = (0, path_1.resolve)(rawWorkdir.replace(/^~/, (0, os_1.homedir)()));
3003
+ if (!(0, fs_1.existsSync)(resolvedWorkdir)) {
3004
+ json(res, { ok: false, error: `workdir does not exist: ${resolvedWorkdir}` }, 400);
3005
+ return;
3006
+ }
3007
+ let st;
3008
+ try {
3009
+ st = (0, fs_1.statSync)(resolvedWorkdir);
3010
+ }
3011
+ catch {
3012
+ json(res, { ok: false, error: `Could not access workdir: ${resolvedWorkdir}` }, 400);
3013
+ return;
3014
+ }
3015
+ if (!st.isDirectory()) {
3016
+ json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
3017
+ return;
3018
+ }
3019
+ pruneWebTerminalInitJobs();
3020
+ const job = createWebTerminalInitJob(agent, resolvedWorkdir);
3021
+ webTerminalInitJobs.set(job.id, job);
3022
+ void runWebTerminalInitJob(job.id);
3023
+ json(res, { ok: true, init: serializeWebTerminalInitJob(job) }, 202);
3024
+ }
3025
+ catch (err) {
3026
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
3027
+ }
3028
+ }
3029
+ async function handlePostWebTerminalAgentUpdate(req, res) {
3030
+ try {
3031
+ const body = await readBody(req);
3032
+ const parsed = JSON.parse(body || '{}');
3033
+ const agent = normalizeWebTerminalAgent(parsed.agent || '');
3034
+ if (!agent) {
3035
+ json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
3036
+ return;
3037
+ }
3038
+ const config = (0, config_js_1.loadConfig)();
3039
+ const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
3040
+ if (!runtimeReady.ok) {
3041
+ json(res, {
3042
+ ok: false,
3043
+ code: 'runtime_unavailable',
3044
+ phase: 'runtime_setup',
3045
+ initialized: runtimeReady.initialized,
3046
+ error: runtimeReady.error || 'Container runtime unavailable.',
3047
+ }, runtimeReady.initialized ? 502 : 503);
3048
+ return;
3049
+ }
3050
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
3051
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
3052
+ json(res, {
3053
+ ok: false,
3054
+ code: 'runtime_unavailable',
3055
+ phase: 'runtime_setup',
3056
+ initialized: runtimeReady.initialized,
3057
+ error: runtimeCheck.error || 'Container runtime unavailable.',
3058
+ }, runtimeReady.initialized ? 502 : 503);
3059
+ return;
3060
+ }
1931
3061
  try {
1932
- await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint);
1933
- (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
3062
+ await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image);
1934
3063
  }
1935
3064
  catch (err) {
1936
- const message = err?.message ?? String(err);
1937
- (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
1938
- json(res, { ok: false, error: `Could not start tmux session: ${message}` }, 500);
3065
+ const detail = commandErrorDetail(err);
3066
+ json(res, {
3067
+ ok: false,
3068
+ code: 'image_prepare_failed',
3069
+ phase: 'image_prepare',
3070
+ runtime: runtimeCheck.runtime,
3071
+ image: config.image,
3072
+ error: detail || `Failed to pull image ${config.image}.`,
3073
+ }, 502);
1939
3074
  return;
1940
3075
  }
1941
- const bridge = await ensureWebTerminalBridge(record);
1942
- if (!bridge) {
1943
- try {
1944
- await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
1945
- }
1946
- catch { /* best effort */ }
1947
- (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, {
1948
- status: 'failed',
1949
- exitCode: 1,
1950
- error: 'node-pty bridge unavailable',
3076
+ try {
3077
+ const updated = await updateWebTerminalAgentCli(runtimeCheck.runtime, config.image, agent, (0, os_1.homedir)(), config.network.mode);
3078
+ const spec = getWebTerminalAgentBootstrapSpec(agent);
3079
+ json(res, {
3080
+ ok: true,
3081
+ agent,
3082
+ pkg: spec.pkg,
3083
+ runtime: runtimeCheck.runtime,
3084
+ image: config.image,
3085
+ version: updated.version,
3086
+ restartNotice: `Restart active ${agent} sessions to use the updated CLI.`,
1951
3087
  });
3088
+ }
3089
+ catch (err) {
3090
+ const detail = commandErrorDetail(err);
1952
3091
  json(res, {
1953
3092
  ok: false,
1954
- error: 'Started tmux session but could not create terminal bridge (node-pty unavailable).',
1955
- }, 500);
1956
- return;
3093
+ code: 'agent_update_failed',
3094
+ phase: 'agent_prepare',
3095
+ agent,
3096
+ runtime: runtimeCheck.runtime,
3097
+ image: config.image,
3098
+ error: detail || `Failed to update ${agent} CLI.`,
3099
+ }, 502);
1957
3100
  }
1958
- json(res, {
1959
- ok: true,
1960
- session: serializeWebTerminalSession(record),
1961
- });
1962
3101
  }
1963
3102
  catch (err) {
1964
3103
  json(res, { ok: false, error: err?.message ?? String(err) }, 500);
1965
3104
  }
1966
3105
  }
3106
+ async function handleGetWebTerminalInit(reqUrl, res) {
3107
+ pruneWebTerminalInitJobs();
3108
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
3109
+ if (!id) {
3110
+ json(res, { ok: false, error: 'id is required' }, 400);
3111
+ return;
3112
+ }
3113
+ const job = webTerminalInitJobs.get(id);
3114
+ if (!job) {
3115
+ json(res, { ok: false, error: 'Initialization job not found' }, 404);
3116
+ return;
3117
+ }
3118
+ json(res, { ok: true, init: serializeWebTerminalInitJob(job) });
3119
+ }
1967
3120
  async function handleGetWebTerminalSessions(res) {
1968
3121
  const records = (0, web_terminal_js_1.listWebTerminalRecords)();
1969
3122
  const localNode = (0, os_1.hostname)();
@@ -1990,6 +3143,82 @@ async function handleGetWebTerminalSessions(res) {
1990
3143
  const sessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
1991
3144
  json(res, { ok: true, sessions });
1992
3145
  }
3146
+ async function handleGetWebTerminalHistory(reqUrl, res) {
3147
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
3148
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
3149
+ json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
3150
+ return;
3151
+ }
3152
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
3153
+ if (!record) {
3154
+ json(res, { ok: false, error: 'Terminal session not found' }, 404);
3155
+ return;
3156
+ }
3157
+ if (record.node !== (0, os_1.hostname)()) {
3158
+ json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
3159
+ return;
3160
+ }
3161
+ const beforeRaw = String(reqUrl.searchParams.get('before') || '').trim();
3162
+ let beforeSeq = null;
3163
+ if (beforeRaw) {
3164
+ const parsedBefore = Number(beforeRaw);
3165
+ if (!Number.isFinite(parsedBefore)) {
3166
+ json(res, { ok: false, error: 'Invalid before sequence number' }, 400);
3167
+ return;
3168
+ }
3169
+ beforeSeq = Math.max(0, Math.floor(parsedBefore));
3170
+ }
3171
+ const limitRaw = String(reqUrl.searchParams.get('limit') || '').trim();
3172
+ let limit = WEB_TERMINAL_HISTORY_PAGE_DEFAULT;
3173
+ if (limitRaw) {
3174
+ const parsedLimit = Number(limitRaw);
3175
+ if (!Number.isFinite(parsedLimit)) {
3176
+ json(res, { ok: false, error: 'Invalid history limit' }, 400);
3177
+ return;
3178
+ }
3179
+ limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(parsedLimit)));
3180
+ }
3181
+ const bridge = await ensureWebTerminalBridge(record);
3182
+ if (!bridge) {
3183
+ json(res, { ok: false, error: 'Could not open terminal bridge' }, 500);
3184
+ return;
3185
+ }
3186
+ const page = getWebTerminalHistoryPage(bridge, { beforeSeq, limit });
3187
+ json(res, {
3188
+ ok: true,
3189
+ id: record.id,
3190
+ history: {
3191
+ ...page,
3192
+ limit,
3193
+ },
3194
+ });
3195
+ }
3196
+ async function handlePostWebTerminalRename(req, res) {
3197
+ try {
3198
+ const body = await readBody(req);
3199
+ const parsed = JSON.parse(body || '{}');
3200
+ const id = String(parsed.id || '').trim();
3201
+ const name = typeof parsed.name === 'string' ? parsed.name.trim() : '';
3202
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
3203
+ json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
3204
+ return;
3205
+ }
3206
+ if (name && !(0, web_terminal_js_1.isValidWebTerminalName)(name)) {
3207
+ json(res, { ok: false, error: 'Invalid session name format' }, 400);
3208
+ return;
3209
+ }
3210
+ const result = (0, web_terminal_js_1.renameWebTerminalRecord)(id, name);
3211
+ if (!result.ok) {
3212
+ const status = result.code === 'name_taken' ? 409 : result.code === 'not_found' ? 404 : 400;
3213
+ json(res, { ok: false, error: result.error, code: result.code }, status);
3214
+ return;
3215
+ }
3216
+ json(res, { ok: true, session: serializeWebTerminalSession(result.record) });
3217
+ }
3218
+ catch (err) {
3219
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
3220
+ }
3221
+ }
1993
3222
  async function handlePostWebTerminalStop(req, res) {
1994
3223
  try {
1995
3224
  const body = await readBody(req);
@@ -2066,8 +3295,15 @@ async function handleValidatePath(req, res) {
2066
3295
  async function handleBrowseDir(req, res) {
2067
3296
  try {
2068
3297
  const body = await readBody(req);
2069
- const { path: rawPath } = JSON.parse(body);
2070
- const resolved = (rawPath && typeof rawPath === 'string' ? rawPath : '~').replace(/^~/, (0, os_1.homedir)());
3298
+ const parsed = JSON.parse(body);
3299
+ const rawPath = typeof parsed.path === 'string' ? parsed.path : '~';
3300
+ const includeFiles = !!parsed.includeFiles;
3301
+ const includeHidden = !!parsed.includeHidden;
3302
+ const rawLimit = Number(parsed.maxEntries);
3303
+ const maxEntries = Number.isFinite(rawLimit)
3304
+ ? Math.max(100, Math.min(5000, Math.floor(rawLimit)))
3305
+ : 2000;
3306
+ const resolved = rawPath.replace(/^~/, (0, os_1.homedir)());
2071
3307
  if (!(0, fs_1.existsSync)(resolved) || !(0, fs_1.statSync)(resolved).isDirectory()) {
2072
3308
  json(res, { ok: false, error: 'Not a directory', path: resolved });
2073
3309
  return;
@@ -2081,19 +3317,41 @@ async function handleBrowseDir(req, res) {
2081
3317
  return;
2082
3318
  }
2083
3319
  const dirs = [];
3320
+ const files = [];
3321
+ let truncated = false;
2084
3322
  for (const entry of entries) {
2085
- if (entry.startsWith('.'))
2086
- continue; // skip dotfiles
3323
+ if (!includeHidden && entry.startsWith('.'))
3324
+ continue; // skip dotfiles by default
2087
3325
  const full = (0, path_1.join)(resolved, entry);
2088
3326
  try {
2089
- if ((0, fs_1.statSync)(full).isDirectory()) {
3327
+ const st = (0, fs_1.statSync)(full);
3328
+ if (st.isDirectory()) {
2090
3329
  dirs.push({ name: entry, path: full });
2091
3330
  }
3331
+ else if (includeFiles && st.isFile()) {
3332
+ files.push({ name: entry, path: full });
3333
+ }
2092
3334
  }
2093
3335
  catch { /* skip inaccessible */ }
3336
+ if ((dirs.length + files.length) >= maxEntries) {
3337
+ truncated = true;
3338
+ break;
3339
+ }
2094
3340
  }
2095
3341
  dirs.sort((a, b) => a.name.localeCompare(b.name));
2096
- json(res, { ok: true, path: resolved, dirs });
3342
+ files.sort((a, b) => a.name.localeCompare(b.name));
3343
+ const entriesOut = [
3344
+ ...dirs.map((d) => ({ name: d.name, path: d.path, type: 'dir' })),
3345
+ ...files.map((f) => ({ name: f.name, path: f.path, type: 'file' })),
3346
+ ];
3347
+ json(res, {
3348
+ ok: true,
3349
+ path: resolved,
3350
+ dirs,
3351
+ files: includeFiles ? files : undefined,
3352
+ entries: includeFiles ? entriesOut : undefined,
3353
+ truncated,
3354
+ });
2097
3355
  }
2098
3356
  catch (err) {
2099
3357
  json(res, { ok: false, error: err.message ?? String(err) }, 400);
@@ -2413,6 +3671,10 @@ function mapContainerPathToHost(path, sandboxHome) {
2413
3671
  return (0, config_js_1.getConfigPath)();
2414
3672
  if (path === '/labgate-config/slurm.db')
2415
3673
  return (0, config_js_1.getSlurmDbPath)();
3674
+ if (path === '/labgate-config/results.json')
3675
+ return (0, config_js_1.getResultsDbPath)();
3676
+ if (path === '/labgate-config/display.json')
3677
+ return (0, config_js_1.getDisplayDbPath)();
2416
3678
  return path;
2417
3679
  }
2418
3680
  function readMcpConfigData() {
@@ -2585,7 +3847,7 @@ function collectMcpState() {
2585
3847
  env: resultsEntry?.env || null,
2586
3848
  mcpConfigPath,
2587
3849
  serverPath: resolveServerPathFromEntry(resultsEntry, sandboxHome),
2588
- dbPath: null,
3850
+ dbPath: resolveDbPathFromEntry(resultsEntry, sandboxHome),
2589
3851
  tools: [
2590
3852
  { name: 'list_results', title: 'List Results', description: 'List recorded results with filtering and pagination' },
2591
3853
  { name: 'register_result', title: 'Register Result', description: 'Create a new structured result entry' },
@@ -3168,45 +4430,210 @@ function handleGetSlurmStats(res) {
3168
4430
  }
3169
4431
  // ── SSE: Server-Sent Events for real-time dashboard updates ──
3170
4432
  const sseClients = new Set();
3171
- function handleSSE(_req, res) {
3172
- res.writeHead(200, {
3173
- 'Content-Type': 'text/event-stream',
3174
- 'Cache-Control': 'no-cache',
3175
- 'Connection': 'keep-alive',
3176
- 'Access-Control-Allow-Origin': '*',
4433
+ const RESULTS_WATCH_DEBOUNCE_MS = 120;
4434
+ let lastResultsSignature = getResultsFileSignature();
4435
+ let resultsWatcher = null;
4436
+ let resultsWatchDebounce = null;
4437
+ function getResultsFileSignature() {
4438
+ const resultsPath = (0, config_js_1.getResultsDbPath)();
4439
+ try {
4440
+ if (!(0, fs_1.existsSync)(resultsPath))
4441
+ return 'missing';
4442
+ const st = (0, fs_1.statSync)(resultsPath);
4443
+ return `${st.size}:${Math.floor(st.mtimeMs)}`;
4444
+ }
4445
+ catch {
4446
+ return 'error';
4447
+ }
4448
+ }
4449
+ function maybeBroadcastResultsChanged() {
4450
+ const signature = getResultsFileSignature();
4451
+ if (signature === lastResultsSignature)
4452
+ return;
4453
+ if (sseClients.size === 0)
4454
+ return;
4455
+ lastResultsSignature = signature;
4456
+ broadcastSSE('results_changed', {
4457
+ changed_at: new Date().toISOString(),
4458
+ signature,
3177
4459
  });
3178
- res.write(':\n\n'); // comment to establish connection
3179
- sseClients.add(res);
3180
- res.on('close', () => sseClients.delete(res));
3181
4460
  }
3182
- function broadcastSSE(event, data) {
3183
- const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
3184
- for (const client of sseClients) {
4461
+ function scheduleResultsChangeCheck(delayMs = RESULTS_WATCH_DEBOUNCE_MS) {
4462
+ if (resultsWatchDebounce) {
4463
+ clearTimeout(resultsWatchDebounce);
4464
+ }
4465
+ resultsWatchDebounce = setTimeout(() => {
4466
+ resultsWatchDebounce = null;
4467
+ maybeBroadcastResultsChanged();
4468
+ }, delayMs);
4469
+ resultsWatchDebounce.unref?.();
4470
+ }
4471
+ function stopResultsWatcher() {
4472
+ if (resultsWatchDebounce) {
4473
+ clearTimeout(resultsWatchDebounce);
4474
+ resultsWatchDebounce = null;
4475
+ }
4476
+ if (resultsWatcher) {
3185
4477
  try {
3186
- client.write(payload);
4478
+ resultsWatcher.close();
3187
4479
  }
3188
4480
  catch {
3189
- sseClients.delete(client);
4481
+ // Best effort.
3190
4482
  }
4483
+ resultsWatcher = null;
3191
4484
  }
3192
4485
  }
3193
- // Push session updates to all connected SSE clients every 2 seconds
3194
- let sseInterval = null;
3195
- function startSSEBroadcast() {
3196
- if (sseInterval)
4486
+ function startResultsWatcher() {
4487
+ if (resultsWatcher)
3197
4488
  return;
3198
- sseInterval = setInterval(async () => {
3199
- if (sseClients.size === 0)
3200
- return;
3201
- // Reuse handleGetSessions logic
3202
- const dir = (0, config_js_1.getSessionsDir)();
3203
- const sessions = [];
3204
- if ((0, fs_1.existsSync)(dir)) {
3205
- const localHost = (0, os_1.hostname)();
3206
- const files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'));
3207
- for (const file of files) {
3208
- try {
3209
- const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
4489
+ lastResultsSignature = getResultsFileSignature();
4490
+ const resultsPath = (0, config_js_1.getResultsDbPath)();
4491
+ const watchDir = (0, path_1.dirname)(resultsPath);
4492
+ const watchFile = (0, path_1.basename)(resultsPath);
4493
+ try {
4494
+ (0, config_js_1.ensurePrivateDir)(watchDir);
4495
+ }
4496
+ catch {
4497
+ // Best effort.
4498
+ }
4499
+ try {
4500
+ resultsWatcher = (0, fs_1.watch)(watchDir, (_eventType, filename) => {
4501
+ const changed = filename ? String(filename) : '';
4502
+ if (changed && changed !== watchFile)
4503
+ return;
4504
+ scheduleResultsChangeCheck();
4505
+ });
4506
+ resultsWatcher.on('error', () => {
4507
+ stopResultsWatcher();
4508
+ });
4509
+ }
4510
+ catch {
4511
+ resultsWatcher = null;
4512
+ }
4513
+ }
4514
+ // ── Display (widgets) file watcher ──
4515
+ const DISPLAY_WATCH_DEBOUNCE_MS = 120;
4516
+ let lastDisplaySignature = getDisplayFileSignature();
4517
+ let displayWatcher = null;
4518
+ let displayWatchDebounce = null;
4519
+ function getDisplayFileSignature() {
4520
+ const displayPath = (0, config_js_1.getDisplayDbPath)();
4521
+ try {
4522
+ if (!(0, fs_1.existsSync)(displayPath))
4523
+ return 'missing';
4524
+ const st = (0, fs_1.statSync)(displayPath);
4525
+ return `${st.size}:${Math.floor(st.mtimeMs)}`;
4526
+ }
4527
+ catch {
4528
+ return 'error';
4529
+ }
4530
+ }
4531
+ function maybeBroadcastWidgetsChanged() {
4532
+ const signature = getDisplayFileSignature();
4533
+ if (signature === lastDisplaySignature)
4534
+ return;
4535
+ if (sseClients.size === 0)
4536
+ return;
4537
+ lastDisplaySignature = signature;
4538
+ broadcastSSE('widgets_changed', {
4539
+ changed_at: new Date().toISOString(),
4540
+ signature,
4541
+ });
4542
+ }
4543
+ function scheduleDisplayChangeCheck(delayMs = DISPLAY_WATCH_DEBOUNCE_MS) {
4544
+ if (displayWatchDebounce) {
4545
+ clearTimeout(displayWatchDebounce);
4546
+ }
4547
+ displayWatchDebounce = setTimeout(() => {
4548
+ displayWatchDebounce = null;
4549
+ maybeBroadcastWidgetsChanged();
4550
+ }, delayMs);
4551
+ displayWatchDebounce.unref?.();
4552
+ }
4553
+ function stopDisplayWatcher() {
4554
+ if (displayWatchDebounce) {
4555
+ clearTimeout(displayWatchDebounce);
4556
+ displayWatchDebounce = null;
4557
+ }
4558
+ if (displayWatcher) {
4559
+ try {
4560
+ displayWatcher.close();
4561
+ }
4562
+ catch {
4563
+ // Best effort.
4564
+ }
4565
+ displayWatcher = null;
4566
+ }
4567
+ }
4568
+ function startDisplayWatcher() {
4569
+ if (displayWatcher)
4570
+ return;
4571
+ lastDisplaySignature = getDisplayFileSignature();
4572
+ const displayPath = (0, config_js_1.getDisplayDbPath)();
4573
+ const watchDir = (0, path_1.dirname)(displayPath);
4574
+ const watchFile = (0, path_1.basename)(displayPath);
4575
+ try {
4576
+ (0, config_js_1.ensurePrivateDir)(watchDir);
4577
+ }
4578
+ catch {
4579
+ // Best effort.
4580
+ }
4581
+ try {
4582
+ displayWatcher = (0, fs_1.watch)(watchDir, (_eventType, filename) => {
4583
+ const changed = filename ? String(filename) : '';
4584
+ if (changed && changed !== watchFile)
4585
+ return;
4586
+ scheduleDisplayChangeCheck();
4587
+ });
4588
+ displayWatcher.on('error', () => {
4589
+ stopDisplayWatcher();
4590
+ });
4591
+ }
4592
+ catch {
4593
+ displayWatcher = null;
4594
+ }
4595
+ }
4596
+ function handleSSE(_req, res) {
4597
+ res.writeHead(200, {
4598
+ 'Content-Type': 'text/event-stream',
4599
+ 'Cache-Control': 'no-cache',
4600
+ 'Connection': 'keep-alive',
4601
+ 'Access-Control-Allow-Origin': '*',
4602
+ });
4603
+ res.write(':\n\n'); // comment to establish connection
4604
+ sseClients.add(res);
4605
+ res.on('close', () => sseClients.delete(res));
4606
+ }
4607
+ function broadcastSSE(event, data) {
4608
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
4609
+ for (const client of sseClients) {
4610
+ try {
4611
+ client.write(payload);
4612
+ }
4613
+ catch {
4614
+ sseClients.delete(client);
4615
+ }
4616
+ }
4617
+ }
4618
+ // Push session updates to all connected SSE clients every 2 seconds
4619
+ let sseInterval = null;
4620
+ function startSSEBroadcast() {
4621
+ if (sseInterval)
4622
+ return;
4623
+ startResultsWatcher();
4624
+ startDisplayWatcher();
4625
+ sseInterval = setInterval(async () => {
4626
+ if (sseClients.size === 0)
4627
+ return;
4628
+ // Reuse handleGetSessions logic
4629
+ const dir = (0, config_js_1.getSessionsDir)();
4630
+ const sessions = [];
4631
+ if ((0, fs_1.existsSync)(dir)) {
4632
+ const localHost = (0, os_1.hostname)();
4633
+ const files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'));
4634
+ for (const file of files) {
4635
+ try {
4636
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
3210
4637
  if (data.node === localHost) {
3211
4638
  try {
3212
4639
  process.kill(data.pid, 0);
@@ -3271,6 +4698,8 @@ function startSSEBroadcast() {
3271
4698
  }
3272
4699
  catch { /* slurm DB unavailable */ }
3273
4700
  }
4701
+ // Results changes may come from external MCP processes; emit only on file mutation.
4702
+ maybeBroadcastResultsChanged();
3274
4703
  }, 2000);
3275
4704
  sseInterval.unref?.();
3276
4705
  }
@@ -3337,136 +4766,778 @@ function handleGetAdminPolicy(_req, res) {
3337
4766
  json(res, { ok: true, policy, bootstrap: false });
3338
4767
  return;
3339
4768
  }
3340
- if (context.bootstrapPolicySetup) {
4769
+ if (context.bootstrapPolicySetup) {
4770
+ json(res, {
4771
+ ok: true,
4772
+ bootstrap: true,
4773
+ policy: buildBootstrapPolicyTemplate(context),
4774
+ message: `No policy is configured yet. Saving this file will make "${context.currentUser}" the initial admin.`,
4775
+ });
4776
+ return;
4777
+ }
4778
+ json(res, { ok: false, error: 'No policy file found' }, 404);
4779
+ }
4780
+ async function handlePostAdminPolicy(req, res) {
4781
+ const context = requireAdmin(res, { allowBootstrapPolicySetup: true });
4782
+ if (!context)
4783
+ return;
4784
+ try {
4785
+ const body = await readBody(req);
4786
+ const incoming = JSON.parse(body);
4787
+ const errors = (0, policy_js_1.validatePolicy)(incoming);
4788
+ if (errors.length > 0) {
4789
+ json(res, { ok: false, errors }, 400);
4790
+ return;
4791
+ }
4792
+ let policy = incoming;
4793
+ let addedBootstrapAdmin = false;
4794
+ // First-time setup guard: ensure the bootstrap user cannot lock themselves out.
4795
+ if (context.bootstrapPolicySetup) {
4796
+ const names = Array.isArray(policy.admins?.usernames) ? policy.admins.usernames : [];
4797
+ const alreadyIncluded = names.includes(context.currentUser);
4798
+ policy = {
4799
+ ...policy,
4800
+ admins: {
4801
+ usernames: [...new Set([context.currentUser, ...names])],
4802
+ },
4803
+ };
4804
+ addedBootstrapAdmin = !alreadyIncluded;
4805
+ }
4806
+ (0, policy_js_1.savePolicy)(policy);
4807
+ json(res, {
4808
+ ok: true,
4809
+ bootstrapCompleted: context.bootstrapPolicySetup,
4810
+ addedBootstrapAdmin,
4811
+ });
4812
+ }
4813
+ catch (err) {
4814
+ json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
4815
+ }
4816
+ }
4817
+ function handleGetAdminUsers(_req, res) {
4818
+ if (!requireAdmin(res))
4819
+ return;
4820
+ const effective = (0, config_js_1.loadEffectiveConfig)();
4821
+ const sharedDir = effective.sharedSessionsDir;
4822
+ // Collect sessions from shared dir or fall back to local sessions
4823
+ const sessionsDir = sharedDir && (0, fs_1.existsSync)(sharedDir) ? sharedDir : (0, config_js_1.getSessionsDir)();
4824
+ const sessions = [];
4825
+ if ((0, fs_1.existsSync)(sessionsDir)) {
4826
+ const files = (0, fs_1.readdirSync)(sessionsDir).filter((f) => f.endsWith('.json'));
4827
+ for (const f of files) {
4828
+ try {
4829
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(sessionsDir, f), 'utf-8'));
4830
+ sessions.push(data);
4831
+ }
4832
+ catch {
4833
+ // Skip malformed session files
4834
+ }
4835
+ }
4836
+ }
4837
+ // Group by user (sessions have a 'user' field when written to shared dir)
4838
+ const userMap = new Map();
4839
+ for (const s of sessions) {
4840
+ const user = s.user ?? 'unknown';
4841
+ if (!userMap.has(user)) {
4842
+ userMap.set(user, { sessions: [], lastActivity: '' });
4843
+ }
4844
+ const entry = userMap.get(user);
4845
+ entry.sessions.push(s);
4846
+ const started = s.started ?? '';
4847
+ if (started > entry.lastActivity)
4848
+ entry.lastActivity = started;
4849
+ }
4850
+ const users = [...userMap.entries()].map(([username, data]) => ({
4851
+ username,
4852
+ activeSessions: data.sessions.length,
4853
+ lastActivity: data.lastActivity,
4854
+ sessions: data.sessions,
4855
+ }));
4856
+ json(res, { ok: true, users });
4857
+ }
4858
+ function handleGetAdminLogs(_req, res) {
4859
+ if (!requireAdmin(res))
4860
+ return;
4861
+ const effective = (0, config_js_1.loadEffectiveConfig)();
4862
+ const logDir = effective.sharedAuditDir && (0, fs_1.existsSync)(effective.sharedAuditDir)
4863
+ ? effective.sharedAuditDir
4864
+ : (0, config_js_1.getLogDir)(effective.config);
4865
+ if (!(0, fs_1.existsSync)(logDir)) {
4866
+ json(res, { ok: true, logs: [] });
4867
+ return;
4868
+ }
4869
+ const files = (0, fs_1.readdirSync)(logDir)
4870
+ .filter((f) => f.endsWith('.jsonl'))
4871
+ .sort()
4872
+ .reverse();
4873
+ const logs = [];
4874
+ const maxEntries = 100;
4875
+ for (const f of files) {
4876
+ if (logs.length >= maxEntries)
4877
+ break;
4878
+ try {
4879
+ const content = (0, fs_1.readFileSync)((0, path_1.join)(logDir, f), 'utf-8');
4880
+ const lines = content.split('\n').filter(l => l.trim());
4881
+ for (const line of lines.reverse()) {
4882
+ if (logs.length >= maxEntries)
4883
+ break;
4884
+ try {
4885
+ logs.push(JSON.parse(line));
4886
+ }
4887
+ catch { /* skip malformed lines */ }
4888
+ }
4889
+ }
4890
+ catch { /* skip unreadable files */ }
4891
+ }
4892
+ json(res, { ok: true, logs });
4893
+ }
4894
+ function handleGetAdminLicense(_req, res) {
4895
+ if (!requireAdmin(res))
4896
+ return;
4897
+ const status = (0, license_js_1.validateLicense)();
4898
+ json(res, { ok: true, license: status });
4899
+ }
4900
+ // ── Display file endpoint ────────────────────────────────
4901
+ // Serves files from the container filesystem to the browser for display widgets.
4902
+ // Maps container paths to host paths using the same mount logic as the session.
4903
+ function resolveDisplayFilePath(containerPath) {
4904
+ const config = (0, config_js_1.loadConfig)();
4905
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
4906
+ // /home/sandbox/... → sandbox home
4907
+ if (containerPath.startsWith('/home/sandbox/')) {
4908
+ return (0, path_1.join)(sandboxHome, containerPath.slice('/home/sandbox/'.length));
4909
+ }
4910
+ // /datasets/<name>/... → dataset host path
4911
+ const datasetMatch = containerPath.match(/^\/datasets\/([^/]+)\/(.+)$/);
4912
+ if (datasetMatch) {
4913
+ const [, dsName, rest] = datasetMatch;
4914
+ const ds = (config.datasets || []).find((d) => d.name === dsName);
4915
+ if (ds) {
4916
+ const resolved = ds.path.replace(/^~/, (0, os_1.homedir)());
4917
+ return (0, path_1.join)(resolved, rest);
4918
+ }
4919
+ }
4920
+ // /work/... → workdir from active sessions
4921
+ if (containerPath.startsWith('/work/')) {
4922
+ const rest = containerPath.slice('/work/'.length);
4923
+ // Check active session workdirs
4924
+ try {
4925
+ const sessionDir = (0, config_js_1.getSessionsDir)();
4926
+ if ((0, fs_1.existsSync)(sessionDir)) {
4927
+ const files = (0, fs_1.readdirSync)(sessionDir).filter((f) => f.endsWith('.json'));
4928
+ for (const f of files) {
4929
+ try {
4930
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(sessionDir, f), 'utf-8'));
4931
+ if (data.workdir) {
4932
+ const candidate = (0, path_1.join)(data.workdir, rest);
4933
+ if ((0, fs_1.existsSync)(candidate))
4934
+ return candidate;
4935
+ }
4936
+ }
4937
+ catch { /* skip */ }
4938
+ }
4939
+ }
4940
+ }
4941
+ catch { /* skip */ }
4942
+ }
4943
+ // /mnt/<basename>/... → extra_paths
4944
+ const mntMatch = containerPath.match(/^\/mnt\/([^/]+)\/(.+)$/);
4945
+ if (mntMatch) {
4946
+ const [, mountBase, rest] = mntMatch;
4947
+ const ep = config.filesystem.extra_paths.find((p) => {
4948
+ const resolved = p.path.replace(/^~/, (0, os_1.homedir)());
4949
+ return (0, path_1.basename)(resolved) === mountBase;
4950
+ });
4951
+ if (ep) {
4952
+ const resolved = ep.path.replace(/^~/, (0, os_1.homedir)());
4953
+ return (0, path_1.join)(resolved, rest);
4954
+ }
4955
+ }
4956
+ return null;
4957
+ }
4958
+ function getContentTypeForFile(filePath) {
4959
+ const ext = filePath.split('.').pop()?.toLowerCase() || '';
4960
+ const mimeMap = {
4961
+ png: 'image/png',
4962
+ jpg: 'image/jpeg',
4963
+ jpeg: 'image/jpeg',
4964
+ gif: 'image/gif',
4965
+ svg: 'image/svg+xml',
4966
+ webp: 'image/webp',
4967
+ pdf: 'application/pdf',
4968
+ csv: 'text/csv',
4969
+ tsv: 'text/tab-separated-values',
4970
+ txt: 'text/plain',
4971
+ json: 'application/json',
4972
+ pdb: 'chemical/x-pdb',
4973
+ cif: 'chemical/x-cif',
4974
+ mmcif: 'chemical/x-mmcif',
4975
+ fasta: 'text/plain',
4976
+ fa: 'text/plain',
4977
+ fastq: 'text/plain',
4978
+ fq: 'text/plain',
4979
+ html: 'text/html',
4980
+ xml: 'application/xml',
4981
+ };
4982
+ return mimeMap[ext] || 'application/octet-stream';
4983
+ }
4984
+ function handleDisplayFile(reqUrl, res) {
4985
+ const containerPath = reqUrl.searchParams.get('path');
4986
+ if (!containerPath) {
4987
+ json(res, { ok: false, error: 'Missing path parameter' }, 400);
4988
+ return;
4989
+ }
4990
+ // Prevent directory traversal
4991
+ if (containerPath.includes('..') || containerPath.includes('\0')) {
4992
+ json(res, { ok: false, error: 'Invalid path' }, 400);
4993
+ return;
4994
+ }
4995
+ const hostPath = resolveDisplayFilePath(containerPath);
4996
+ if (!hostPath) {
4997
+ json(res, { ok: false, error: 'Path not within any allowed mount' }, 404);
4998
+ return;
4999
+ }
5000
+ // Verify the resolved path doesn't escape via symlinks
5001
+ let realPath;
5002
+ try {
5003
+ realPath = (0, fs_1.realpathSync)(hostPath);
5004
+ }
5005
+ catch {
5006
+ json(res, { ok: false, error: 'File not found' }, 404);
5007
+ return;
5008
+ }
5009
+ try {
5010
+ const stat = (0, fs_1.statSync)(realPath);
5011
+ if (!stat.isFile()) {
5012
+ json(res, { ok: false, error: 'Not a file' }, 400);
5013
+ return;
5014
+ }
5015
+ // Limit to 50MB
5016
+ if (stat.size > 50 * 1024 * 1024) {
5017
+ json(res, { ok: false, error: 'File too large (max 50MB)' }, 413);
5018
+ return;
5019
+ }
5020
+ const contentType = getContentTypeForFile(realPath);
5021
+ const data = (0, fs_1.readFileSync)(realPath);
5022
+ res.writeHead(200, {
5023
+ 'Content-Type': contentType,
5024
+ 'Content-Length': data.length,
5025
+ 'Cache-Control': 'no-cache',
5026
+ });
5027
+ res.end(data);
5028
+ }
5029
+ catch {
5030
+ json(res, { ok: false, error: 'Failed to read file' }, 500);
5031
+ }
5032
+ }
5033
+ function handleGetWidgets(res) {
5034
+ try {
5035
+ const store = getDisplayStore();
5036
+ const widgets = store.listEvents();
5037
+ json(res, { ok: true, widgets });
5038
+ }
5039
+ catch (err) {
5040
+ json(res, { ok: false, error: err.message || 'Failed to list widgets' }, 500);
5041
+ }
5042
+ }
5043
+ function handleClearWidgets(res) {
5044
+ try {
5045
+ const store = getDisplayStore();
5046
+ store.clearEvents();
5047
+ json(res, { ok: true });
5048
+ }
5049
+ catch (err) {
5050
+ json(res, { ok: false, error: err.message || 'Failed to clear widgets' }, 500);
5051
+ }
5052
+ }
5053
+ function parseExplorerExperimentId(reqUrl) {
5054
+ return String(reqUrl.searchParams.get('experiment_id') || '').trim();
5055
+ }
5056
+ function parseExplorerRunId(reqUrl) {
5057
+ return String(reqUrl.searchParams.get('run_id') || '').trim();
5058
+ }
5059
+ function parseExplorerListInt(raw, fallback, min, max) {
5060
+ const parsed = Number(raw);
5061
+ if (!Number.isFinite(parsed))
5062
+ return fallback;
5063
+ return Math.max(min, Math.min(max, Math.floor(parsed)));
5064
+ }
5065
+ async function ensureExplorerQuickstartSourceRepo() {
5066
+ if (!(0, fs_1.existsSync)(EXPLORER_TSP_TEMPLATE_DIR)) {
5067
+ throw new Error(`Bundled template not found: ${EXPLORER_TSP_TEMPLATE_DIR}`);
5068
+ }
5069
+ const gitDir = (0, path_1.join)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO, '.git');
5070
+ if ((0, fs_1.existsSync)(gitDir))
5071
+ return EXPLORER_TSP_TEMPLATE_SOURCE_REPO;
5072
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO));
5073
+ (0, fs_1.mkdirSync)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO, { recursive: true, mode: config_js_1.PRIVATE_DIR_MODE });
5074
+ (0, fs_1.cpSync)(EXPLORER_TSP_TEMPLATE_DIR, EXPLORER_TSP_TEMPLATE_SOURCE_REPO, { recursive: true, force: true });
5075
+ await execFileAsync('git', ['init', '.'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
5076
+ await execFileAsync('git', ['add', '.'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
5077
+ try {
5078
+ await execFileAsync('git', ['-c', 'user.name=labgate-ui', '-c', 'user.email=labgate@local', 'commit', '-m', 'template baseline'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
5079
+ }
5080
+ catch (err) {
5081
+ const detail = commandErrorDetail(err);
5082
+ if (!/nothing to commit/i.test(detail)) {
5083
+ throw err;
5084
+ }
5085
+ }
5086
+ return EXPLORER_TSP_TEMPLATE_SOURCE_REPO;
5087
+ }
5088
+ async function parseExplorerQuickstartInput(body) {
5089
+ const nameInput = String(body.name || '').trim();
5090
+ const experimentName = nameInput || `TSP Demo ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`;
5091
+ const modeRaw = String(body.agent_mode || 'stub').trim().toLowerCase();
5092
+ const agentMode = modeRaw === 'claude' ? 'claude_headless' : (modeRaw || 'stub');
5093
+ if (agentMode !== 'stub' && agentMode !== 'claude_headless') {
5094
+ throw new Error('agent_mode must be stub or claude_headless');
5095
+ }
5096
+ const claudeResumeSessionId = String(body.claude_resume_session_id || '').trim();
5097
+ const claudeTimeoutRaw = body.claude_timeout_sec;
5098
+ const claudeTimeout = Number(claudeTimeoutRaw);
5099
+ if (claudeTimeoutRaw !== undefined &&
5100
+ (!Number.isFinite(claudeTimeout) || claudeTimeout < 60 || claudeTimeout > 14_400)) {
5101
+ throw new Error('claude_timeout_sec must be between 60 and 14400');
5102
+ }
5103
+ const sourceRepoInput = String(body.source_repo_path || '').trim();
5104
+ const sourceRepoPath = sourceRepoInput
5105
+ ? (0, path_1.resolve)(sourceRepoInput)
5106
+ : await ensureExplorerQuickstartSourceRepo();
5107
+ if (!(0, fs_1.existsSync)(sourceRepoPath)) {
5108
+ throw new Error(`source_repo_path not found: ${sourceRepoPath}`);
5109
+ }
5110
+ const evalCommand = String(body.eval_command || 'python3 eval.py').trim() || 'python3 eval.py';
5111
+ const timeoutRaw = body.eval_timeout_sec;
5112
+ const evalTimeoutSec = timeoutRaw === undefined ? 30 : Number(timeoutRaw);
5113
+ if (!Number.isFinite(evalTimeoutSec) || evalTimeoutSec < 5 || evalTimeoutSec > 86_400) {
5114
+ throw new Error('eval_timeout_sec must be between 5 and 86400');
5115
+ }
5116
+ const policy = {
5117
+ epsilon: 0.15,
5118
+ top_n: 5,
5119
+ agent_mode: agentMode,
5120
+ };
5121
+ if (agentMode === 'stub') {
5122
+ policy.stub_patch_file = 'stub-patches/enable_two_opt.patch';
5123
+ }
5124
+ else {
5125
+ if (claudeResumeSessionId)
5126
+ policy.claude_resume_session_id = claudeResumeSessionId;
5127
+ if (claudeTimeoutRaw !== undefined)
5128
+ policy.claude_timeout_sec = Math.floor(claudeTimeout);
5129
+ }
5130
+ return {
5131
+ experimentName,
5132
+ sourceRepoPath,
5133
+ evalCommand,
5134
+ evalTimeoutSec: Math.floor(evalTimeoutSec),
5135
+ policy,
5136
+ };
5137
+ }
5138
+ function handleGetExplorerExperiments(reqUrl, res) {
5139
+ const limit = parseExplorerListInt(reqUrl.searchParams.get('limit'), 20, 1, 500);
5140
+ const offset = parseExplorerListInt(reqUrl.searchParams.get('offset'), 0, 0, 100_000);
5141
+ const store = new explorer_store_js_1.ExplorerStore();
5142
+ try {
5143
+ const experiments = store.listExperiments(limit, offset);
5144
+ json(res, {
5145
+ ok: true,
5146
+ experiments,
5147
+ returned: experiments.length,
5148
+ limit,
5149
+ offset,
5150
+ });
5151
+ }
5152
+ catch (err) {
5153
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5154
+ }
5155
+ finally {
5156
+ store.close();
5157
+ }
5158
+ }
5159
+ function handleGetExplorerOverview(reqUrl, res) {
5160
+ const experimentId = parseExplorerExperimentId(reqUrl);
5161
+ if (!experimentId) {
5162
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5163
+ return;
5164
+ }
5165
+ try {
5166
+ const overview = (0, explorer_js_1.getExperimentOverview)(experimentId);
5167
+ if (!overview) {
5168
+ json(res, { ok: false, error: 'Experiment not found' }, 404);
5169
+ return;
5170
+ }
5171
+ json(res, { ok: true, overview });
5172
+ }
5173
+ catch (err) {
5174
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5175
+ }
5176
+ }
5177
+ function handleGetExplorerTree(reqUrl, res) {
5178
+ const experimentId = parseExplorerExperimentId(reqUrl);
5179
+ if (!experimentId) {
5180
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5181
+ return;
5182
+ }
5183
+ const mode = String(reqUrl.searchParams.get('mode') || 'best_path').trim() === 'full' ? 'full' : 'best_path';
5184
+ try {
5185
+ const tree = (0, explorer_js_1.getExperimentTree)(experimentId, mode);
5186
+ json(res, { ok: true, tree });
5187
+ }
5188
+ catch (err) {
5189
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5190
+ }
5191
+ }
5192
+ function handleGetExplorerRuns(reqUrl, res) {
5193
+ const experimentId = parseExplorerExperimentId(reqUrl);
5194
+ if (!experimentId) {
5195
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5196
+ return;
5197
+ }
5198
+ const limit = parseExplorerListInt(reqUrl.searchParams.get('limit'), 20, 1, 500);
5199
+ const offset = parseExplorerListInt(reqUrl.searchParams.get('offset'), 0, 0, 100_000);
5200
+ const store = new explorer_store_js_1.ExplorerStore();
5201
+ try {
5202
+ const runs = store.listRuns(experimentId, { limit, offset });
5203
+ const total = store.getRunCount(experimentId);
5204
+ json(res, { ok: true, runs, total, returned: runs.length, limit, offset });
5205
+ }
5206
+ catch (err) {
5207
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5208
+ }
5209
+ finally {
5210
+ store.close();
5211
+ }
5212
+ }
5213
+ function handleGetExplorerRun(reqUrl, res) {
5214
+ const runId = parseExplorerRunId(reqUrl);
5215
+ if (!runId) {
5216
+ json(res, { ok: false, error: 'Missing run_id' }, 400);
5217
+ return;
5218
+ }
5219
+ try {
5220
+ const details = (0, explorer_js_1.getRunDetails)(runId);
5221
+ if (!details) {
5222
+ json(res, { ok: false, error: 'Run not found' }, 404);
5223
+ return;
5224
+ }
5225
+ json(res, { ok: true, details });
5226
+ }
5227
+ catch (err) {
5228
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5229
+ }
5230
+ }
5231
+ function handleGetExplorerCompare(reqUrl, res) {
5232
+ const experimentId = parseExplorerExperimentId(reqUrl);
5233
+ const runId = parseExplorerRunId(reqUrl);
5234
+ if (!experimentId || !runId) {
5235
+ json(res, { ok: false, error: 'Missing experiment_id or run_id' }, 400);
5236
+ return;
5237
+ }
5238
+ const compareTo = String(reqUrl.searchParams.get('to') || 'best').trim() || 'best';
5239
+ const includePatch = reqUrl.searchParams.get('include_patch') === '1';
5240
+ try {
5241
+ const comparison = (0, explorer_js_1.compareRun)({
5242
+ experiment_id: experimentId,
5243
+ run_id: runId,
5244
+ compare_to: compareTo,
5245
+ include_patch: includePatch,
5246
+ });
5247
+ json(res, { ok: true, comparison });
5248
+ }
5249
+ catch (err) {
5250
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5251
+ }
5252
+ }
5253
+ function handleGetExplorerArtifact(reqUrl, res) {
5254
+ const runId = parseExplorerRunId(reqUrl);
5255
+ if (!runId) {
5256
+ json(res, { ok: false, error: 'Missing run_id' }, 400);
5257
+ return;
5258
+ }
5259
+ const kind = String(reqUrl.searchParams.get('kind') || 'summary').trim().toLowerCase();
5260
+ if (!['summary', 'diff', 'stdout', 'stderr', 'eval', 'agent', 'claude_stdout', 'claude_stderr'].includes(kind)) {
5261
+ json(res, { ok: false, error: 'Invalid kind (expected summary|diff|stdout|stderr|eval|agent|claude_stdout|claude_stderr)' }, 400);
5262
+ return;
5263
+ }
5264
+ const details = (0, explorer_js_1.getRunDetails)(runId);
5265
+ if (!details) {
5266
+ json(res, { ok: false, error: 'Run not found' }, 404);
5267
+ return;
5268
+ }
5269
+ const artifacts = details.artifacts;
5270
+ const pathByKind = {
5271
+ summary: artifacts.summary_path,
5272
+ diff: artifacts.diff_path,
5273
+ stdout: artifacts.stdout_path,
5274
+ stderr: artifacts.stderr_path,
5275
+ eval: artifacts.eval_json_path,
5276
+ agent: artifacts.agent_log_path,
5277
+ claude_stdout: artifacts.claude_stdout_path,
5278
+ claude_stderr: artifacts.claude_stderr_path,
5279
+ };
5280
+ const availableByKind = {
5281
+ summary: artifacts.available.summary,
5282
+ diff: artifacts.available.diff,
5283
+ stdout: artifacts.available.stdout,
5284
+ stderr: artifacts.available.stderr,
5285
+ eval: artifacts.available.eval_json,
5286
+ agent: artifacts.available.agent_log,
5287
+ claude_stdout: artifacts.available.claude_stdout,
5288
+ claude_stderr: artifacts.available.claude_stderr,
5289
+ };
5290
+ const filePath = pathByKind[kind];
5291
+ if (!filePath || !availableByKind[kind] || !(0, fs_1.existsSync)(filePath)) {
5292
+ json(res, {
5293
+ ok: false,
5294
+ error: 'Artifact missing (possibly pruned)',
5295
+ artifacts_pruned: artifacts.artifacts_pruned,
5296
+ worktree_pruned: artifacts.worktree_pruned,
5297
+ }, 404);
5298
+ return;
5299
+ }
5300
+ try {
5301
+ const st = (0, fs_1.statSync)(filePath);
5302
+ if (!st.isFile()) {
5303
+ json(res, { ok: false, error: 'Artifact is not a file' }, 400);
5304
+ return;
5305
+ }
5306
+ const readBytes = Math.min(st.size, EXPLORER_ARTIFACT_READ_MAX_BYTES);
5307
+ const offset = Math.max(0, st.size - readBytes);
5308
+ const fd = (0, fs_1.openSync)(filePath, 'r');
5309
+ const buf = Buffer.alloc(readBytes);
5310
+ try {
5311
+ (0, fs_1.readSync)(fd, buf, 0, readBytes, offset);
5312
+ }
5313
+ finally {
5314
+ (0, fs_1.closeSync)(fd);
5315
+ }
5316
+ let text = buf.toString('utf-8');
5317
+ if (offset > 0) {
5318
+ const firstNewline = text.indexOf('\n');
5319
+ if (firstNewline >= 0)
5320
+ text = text.slice(firstNewline + 1);
5321
+ }
5322
+ if (kind === 'eval') {
5323
+ let parsedEval = null;
5324
+ try {
5325
+ parsedEval = JSON.parse(text);
5326
+ }
5327
+ catch {
5328
+ parsedEval = null;
5329
+ }
5330
+ json(res, {
5331
+ ok: true,
5332
+ kind,
5333
+ path: filePath,
5334
+ size: st.size,
5335
+ truncated: st.size > readBytes,
5336
+ eval: parsedEval,
5337
+ raw: text,
5338
+ });
5339
+ return;
5340
+ }
5341
+ json(res, {
5342
+ ok: true,
5343
+ kind,
5344
+ path: filePath,
5345
+ size: st.size,
5346
+ truncated: st.size > readBytes,
5347
+ text,
5348
+ });
5349
+ }
5350
+ catch (err) {
5351
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5352
+ }
5353
+ }
5354
+ async function handlePostExplorerQuickstart(req, res) {
5355
+ try {
5356
+ let body = {};
5357
+ try {
5358
+ body = JSON.parse(await readBody(req) || '{}');
5359
+ }
5360
+ catch {
5361
+ body = {};
5362
+ }
5363
+ const input = await parseExplorerQuickstartInput(body);
5364
+ const experiment = (0, explorer_js_1.createExplorerExperiment)({
5365
+ name: input.experimentName,
5366
+ source_repo_path: input.sourceRepoPath,
5367
+ eval_command: input.evalCommand,
5368
+ eval_timeout_sec: input.evalTimeoutSec,
5369
+ policy: input.policy,
5370
+ retention: {
5371
+ keep_worktrees: false,
5372
+ artifacts: 'minimal',
5373
+ keep_last_n: 50,
5374
+ keep_best: true,
5375
+ keep_failed_last_n: 20,
5376
+ max_delete_runs: 200,
5377
+ },
5378
+ });
5379
+ const baselineArtifactDir = (0, config_js_1.getExplorerArtifactDir)(experiment.id, 'baseline');
5380
+ const baseline = (0, explorer_eval_js_1.runEvaluation)({
5381
+ worktree_path: experiment.repo_path,
5382
+ eval_command: experiment.eval_command,
5383
+ timeout_sec: experiment.eval_timeout_sec,
5384
+ artifact_dir: baselineArtifactDir,
5385
+ });
5386
+ const store = new explorer_store_js_1.ExplorerStore();
5387
+ try {
5388
+ store.createEvent(experiment.id, 'note', {
5389
+ message: 'baseline evaluation',
5390
+ status: baseline.status,
5391
+ score: baseline.score ?? null,
5392
+ artifact_dir: baselineArtifactDir,
5393
+ error: baseline.error || null,
5394
+ });
5395
+ }
5396
+ finally {
5397
+ store.close();
5398
+ }
5399
+ const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id);
3341
5400
  json(res, {
3342
5401
  ok: true,
3343
- bootstrap: true,
3344
- policy: buildBootstrapPolicyTemplate(context),
3345
- message: `No policy is configured yet. Saving this file will make "${context.currentUser}" the initial admin.`,
5402
+ experiment,
5403
+ baseline: {
5404
+ status: baseline.status,
5405
+ score: baseline.score ?? null,
5406
+ error: baseline.error || null,
5407
+ artifact_dir: baselineArtifactDir,
5408
+ },
5409
+ overview,
3346
5410
  });
3347
- return;
3348
5411
  }
3349
- json(res, { ok: false, error: 'No policy file found' }, 404);
5412
+ catch (err) {
5413
+ const message = err?.message || String(err);
5414
+ const status = /must be|not found/i.test(message) ? 400 : 500;
5415
+ json(res, { ok: false, error: message }, status);
5416
+ }
3350
5417
  }
3351
- async function handlePostAdminPolicy(req, res) {
3352
- const context = requireAdmin(res, { allowBootstrapPolicySetup: true });
3353
- if (!context)
3354
- return;
5418
+ async function handlePostExplorerRegister(req, res) {
3355
5419
  try {
3356
- const body = await readBody(req);
3357
- const incoming = JSON.parse(body);
3358
- const errors = (0, policy_js_1.validatePolicy)(incoming);
3359
- if (errors.length > 0) {
3360
- json(res, { ok: false, errors }, 400);
3361
- return;
3362
- }
3363
- let policy = incoming;
3364
- let addedBootstrapAdmin = false;
3365
- // First-time setup guard: ensure the bootstrap user cannot lock themselves out.
3366
- if (context.bootstrapPolicySetup) {
3367
- const names = Array.isArray(policy.admins?.usernames) ? policy.admins.usernames : [];
3368
- const alreadyIncluded = names.includes(context.currentUser);
3369
- policy = {
3370
- ...policy,
3371
- admins: {
3372
- usernames: [...new Set([context.currentUser, ...names])],
3373
- },
3374
- };
3375
- addedBootstrapAdmin = !alreadyIncluded;
5420
+ let body = {};
5421
+ try {
5422
+ body = JSON.parse(await readBody(req) || '{}');
3376
5423
  }
3377
- (0, policy_js_1.savePolicy)(policy);
5424
+ catch {
5425
+ body = {};
5426
+ }
5427
+ const input = await parseExplorerQuickstartInput(body);
5428
+ const experiment = (0, explorer_js_1.createExplorerExperiment)({
5429
+ name: input.experimentName,
5430
+ source_repo_path: input.sourceRepoPath,
5431
+ eval_command: input.evalCommand,
5432
+ eval_timeout_sec: input.evalTimeoutSec,
5433
+ policy: input.policy,
5434
+ retention: {
5435
+ keep_worktrees: false,
5436
+ artifacts: 'minimal',
5437
+ keep_last_n: 50,
5438
+ keep_best: true,
5439
+ keep_failed_last_n: 20,
5440
+ max_delete_runs: 200,
5441
+ },
5442
+ });
5443
+ const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id);
3378
5444
  json(res, {
3379
5445
  ok: true,
3380
- bootstrapCompleted: context.bootstrapPolicySetup,
3381
- addedBootstrapAdmin,
5446
+ experiment,
5447
+ overview,
5448
+ flow: {
5449
+ tool: 'experiment_register',
5450
+ initialized: false,
5451
+ },
3382
5452
  });
3383
5453
  }
3384
5454
  catch (err) {
3385
- json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
5455
+ const message = err?.message || String(err);
5456
+ const status = /must be|not found/i.test(message) ? 400 : 500;
5457
+ json(res, { ok: false, error: message }, status);
3386
5458
  }
3387
5459
  }
3388
- function handleGetAdminUsers(_req, res) {
3389
- if (!requireAdmin(res))
3390
- return;
3391
- const effective = (0, config_js_1.loadEffectiveConfig)();
3392
- const sharedDir = effective.sharedSessionsDir;
3393
- // Collect sessions from shared dir or fall back to local sessions
3394
- const sessionsDir = sharedDir && (0, fs_1.existsSync)(sharedDir) ? sharedDir : (0, config_js_1.getSessionsDir)();
3395
- const sessions = [];
3396
- if ((0, fs_1.existsSync)(sessionsDir)) {
3397
- const files = (0, fs_1.readdirSync)(sessionsDir).filter((f) => f.endsWith('.json'));
3398
- for (const f of files) {
3399
- try {
3400
- const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(sessionsDir, f), 'utf-8'));
3401
- sessions.push(data);
3402
- }
3403
- catch {
3404
- // Skip malformed session files
5460
+ async function handlePostExplorerInit(req, res) {
5461
+ try {
5462
+ let body = {};
5463
+ try {
5464
+ body = JSON.parse(await readBody(req) || '{}');
5465
+ }
5466
+ catch {
5467
+ body = {};
5468
+ }
5469
+ const experimentId = String(body.experiment_id || '').trim();
5470
+ if (!experimentId) {
5471
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5472
+ return;
5473
+ }
5474
+ const store = new explorer_store_js_1.ExplorerStore();
5475
+ try {
5476
+ const experiment = store.getExperiment(experimentId);
5477
+ if (!experiment) {
5478
+ json(res, { ok: false, error: 'Experiment not found' }, 404);
5479
+ return;
3405
5480
  }
5481
+ const baselineArtifactDir = (0, config_js_1.getExplorerArtifactDir)(experiment.id, 'baseline');
5482
+ const baseline = (0, explorer_eval_js_1.runEvaluation)({
5483
+ worktree_path: experiment.repo_path,
5484
+ eval_command: experiment.eval_command,
5485
+ timeout_sec: experiment.eval_timeout_sec,
5486
+ artifact_dir: baselineArtifactDir,
5487
+ });
5488
+ store.createEvent(experiment.id, 'note', {
5489
+ message: 'experiment initialized with baseline evaluation',
5490
+ status: baseline.status,
5491
+ score: baseline.score ?? null,
5492
+ artifact_dir: baselineArtifactDir,
5493
+ error: baseline.error || null,
5494
+ });
5495
+ const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id, store);
5496
+ json(res, {
5497
+ ok: true,
5498
+ experiment,
5499
+ baseline: {
5500
+ status: baseline.status,
5501
+ score: baseline.score ?? null,
5502
+ error: baseline.error || null,
5503
+ artifact_dir: baselineArtifactDir,
5504
+ },
5505
+ overview,
5506
+ flow: {
5507
+ tool: 'experiment_init',
5508
+ initialized: true,
5509
+ },
5510
+ });
3406
5511
  }
3407
- }
3408
- // Group by user (sessions have a 'user' field when written to shared dir)
3409
- const userMap = new Map();
3410
- for (const s of sessions) {
3411
- const user = s.user ?? 'unknown';
3412
- if (!userMap.has(user)) {
3413
- userMap.set(user, { sessions: [], lastActivity: '' });
5512
+ finally {
5513
+ store.close();
3414
5514
  }
3415
- const entry = userMap.get(user);
3416
- entry.sessions.push(s);
3417
- const started = s.started ?? '';
3418
- if (started > entry.lastActivity)
3419
- entry.lastActivity = started;
3420
5515
  }
3421
- const users = [...userMap.entries()].map(([username, data]) => ({
3422
- username,
3423
- activeSessions: data.sessions.length,
3424
- lastActivity: data.lastActivity,
3425
- sessions: data.sessions,
3426
- }));
3427
- json(res, { ok: true, users });
3428
- }
3429
- function handleGetAdminLogs(_req, res) {
3430
- if (!requireAdmin(res))
3431
- return;
3432
- const effective = (0, config_js_1.loadEffectiveConfig)();
3433
- const logDir = effective.sharedAuditDir && (0, fs_1.existsSync)(effective.sharedAuditDir)
3434
- ? effective.sharedAuditDir
3435
- : (0, config_js_1.getLogDir)(effective.config);
3436
- if (!(0, fs_1.existsSync)(logDir)) {
3437
- json(res, { ok: true, logs: [] });
3438
- return;
5516
+ catch (err) {
5517
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
3439
5518
  }
3440
- const files = (0, fs_1.readdirSync)(logDir)
3441
- .filter((f) => f.endsWith('.jsonl'))
3442
- .sort()
3443
- .reverse();
3444
- const logs = [];
3445
- const maxEntries = 100;
3446
- for (const f of files) {
3447
- if (logs.length >= maxEntries)
3448
- break;
5519
+ }
5520
+ async function handlePostExplorerTick(req, res) {
5521
+ try {
5522
+ let body = {};
3449
5523
  try {
3450
- const content = (0, fs_1.readFileSync)((0, path_1.join)(logDir, f), 'utf-8');
3451
- const lines = content.split('\n').filter(l => l.trim());
3452
- for (const line of lines.reverse()) {
3453
- if (logs.length >= maxEntries)
3454
- break;
3455
- try {
3456
- logs.push(JSON.parse(line));
3457
- }
3458
- catch { /* skip malformed lines */ }
3459
- }
5524
+ body = JSON.parse(await readBody(req) || '{}');
3460
5525
  }
3461
- catch { /* skip unreadable files */ }
5526
+ catch {
5527
+ body = {};
5528
+ }
5529
+ const experimentId = String(body.experiment_id || '').trim();
5530
+ if (!experimentId) {
5531
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5532
+ return;
5533
+ }
5534
+ const result = (0, explorer_js_1.runAutopilotTick)(experimentId);
5535
+ const runDetails = result.run_id ? (0, explorer_js_1.getRunDetails)(result.run_id) : null;
5536
+ json(res, { ok: true, result, run_details: runDetails });
5537
+ }
5538
+ catch (err) {
5539
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
3462
5540
  }
3463
- json(res, { ok: true, logs });
3464
- }
3465
- function handleGetAdminLicense(_req, res) {
3466
- if (!requireAdmin(res))
3467
- return;
3468
- const status = (0, license_js_1.validateLicense)();
3469
- json(res, { ok: true, license: status });
3470
5541
  }
3471
5542
  function upgradeUnauthorized(socket) {
3472
5543
  try {
@@ -3510,6 +5581,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3510
5581
  const socketPath = options.socketPath || (0, config_js_1.getUiSocketPath)();
3511
5582
  const tcpPort = Number.isFinite(options.port) ? Math.floor(options.port) : null;
3512
5583
  const useTcp = tcpPort !== null;
5584
+ const prewarmImageOnStartup = options.prewarmImageOnStartup === true;
3513
5585
  const requestedPort = tcpPort ?? 0;
3514
5586
  const maxPort = requestedPort + 3;
3515
5587
  const uiAccessToken = useTcp ? (0, crypto_1.randomBytes)(24).toString('hex') : '';
@@ -3522,9 +5594,16 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3522
5594
  (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(socketPath));
3523
5595
  }
3524
5596
  const wsServer = new ws_1.WebSocketServer({ noServer: true });
5597
+ const claudeWsServer = new ws_1.WebSocketServer({ noServer: true });
3525
5598
  wsServer.on('connection', async (ws, req) => {
3526
5599
  const reqUrl = new URL(req.url || '/', 'http://localhost');
3527
5600
  const id = reqUrl.searchParams.get('id') || '';
5601
+ const replayRaw = String(reqUrl.searchParams.get('replay') || '').trim().toLowerCase();
5602
+ const replayDisabled = replayRaw === '0' || replayRaw === 'false' || replayRaw === 'off';
5603
+ const afterSeqRaw = String(reqUrl.searchParams.get('afterSeq') || '').trim();
5604
+ const parsedAfterSeq = afterSeqRaw ? Number(afterSeqRaw) : NaN;
5605
+ const hasAfterSeq = afterSeqRaw.length > 0 && Number.isFinite(parsedAfterSeq);
5606
+ const afterSeq = hasAfterSeq ? Math.floor(parsedAfterSeq) : null;
3528
5607
  const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
3529
5608
  if (!record) {
3530
5609
  sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found' });
@@ -3545,7 +5624,19 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3545
5624
  exitCode: record.exitCode,
3546
5625
  error: record.error,
3547
5626
  });
3548
- if (bridge.buffer) {
5627
+ if (afterSeq !== null) {
5628
+ const pending = bridge.history.filter((chunk) => chunk.seq > afterSeq);
5629
+ for (const chunk of pending) {
5630
+ sendWebTerminalMessage(ws, {
5631
+ type: 'data',
5632
+ id: record.id,
5633
+ data: chunk.data,
5634
+ seqStart: chunk.seq,
5635
+ seqEnd: chunk.seq,
5636
+ });
5637
+ }
5638
+ }
5639
+ else if (!replayDisabled && bridge.buffer) {
3549
5640
  sendWebTerminalMessage(ws, { type: 'data', id: record.id, data: bridge.buffer });
3550
5641
  }
3551
5642
  ws.on('message', (raw) => {
@@ -3586,6 +5677,65 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3586
5677
  }
3587
5678
  });
3588
5679
  });
5680
+ claudeWsServer.on('connection', (ws, req) => {
5681
+ const reqUrl = new URL(req.url || '/', 'http://localhost');
5682
+ const id = (reqUrl.searchParams.get('id') || '').trim();
5683
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
5684
+ if (!record || record.node !== (0, os_1.hostname)()) {
5685
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found on this node.' });
5686
+ ws.close();
5687
+ return;
5688
+ }
5689
+ if (record.agent !== 'claude') {
5690
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Headless chat is currently supported for Claude sessions only.' });
5691
+ ws.close();
5692
+ return;
5693
+ }
5694
+ let disposeRun = null;
5695
+ let runInFlight = false;
5696
+ ws.on('message', (raw) => {
5697
+ if (runInFlight) {
5698
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Headless Claude run already in progress for this socket.' });
5699
+ return;
5700
+ }
5701
+ let parsed = null;
5702
+ try {
5703
+ parsed = JSON.parse(raw.toString('utf-8'));
5704
+ }
5705
+ catch {
5706
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Invalid request payload.' });
5707
+ return;
5708
+ }
5709
+ if (!parsed || parsed.type !== 'prompt') {
5710
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Unsupported request. Expected { type: "prompt", prompt }.' });
5711
+ return;
5712
+ }
5713
+ const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
5714
+ const resumeSessionId = typeof parsed.resumeSessionId === 'string' ? parsed.resumeSessionId : '';
5715
+ runInFlight = true;
5716
+ void startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId)
5717
+ .then((cleanup) => {
5718
+ disposeRun = cleanup;
5719
+ })
5720
+ .catch((err) => {
5721
+ sendWebTerminalMessage(ws, {
5722
+ type: 'error',
5723
+ code: 'headless_failed',
5724
+ error: err?.message ?? String(err),
5725
+ });
5726
+ sendWebTerminalMessage(ws, { type: 'done', exitCode: 1, isError: true });
5727
+ });
5728
+ });
5729
+ ws.on('close', () => {
5730
+ if (disposeRun) {
5731
+ try {
5732
+ disposeRun();
5733
+ }
5734
+ catch { /* best effort */ }
5735
+ }
5736
+ disposeRun = null;
5737
+ });
5738
+ });
3589
5739
  const server = (0, http_1.createServer)(async (req, res) => {
3590
5740
  const url = req.url ?? '/';
3591
5741
  const reqUrl = new URL(url, 'http://localhost');
@@ -3711,9 +5861,24 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3711
5861
  else if (pathname === '/api/terminal/sessions' && method === 'GET') {
3712
5862
  await handleGetWebTerminalSessions(res);
3713
5863
  }
5864
+ else if (pathname === '/api/terminal/history' && method === 'GET') {
5865
+ await handleGetWebTerminalHistory(reqUrl, res);
5866
+ }
5867
+ else if (pathname === '/api/terminal/init' && method === 'GET') {
5868
+ await handleGetWebTerminalInit(reqUrl, res);
5869
+ }
5870
+ else if (pathname === '/api/terminal/init' && method === 'POST') {
5871
+ await handlePostWebTerminalInit(req, res);
5872
+ }
3714
5873
  else if (pathname === '/api/terminal/start' && method === 'POST') {
3715
5874
  await handlePostWebTerminalStart(req, res);
3716
5875
  }
5876
+ else if (pathname === '/api/terminal/agent/update' && method === 'POST') {
5877
+ await handlePostWebTerminalAgentUpdate(req, res);
5878
+ }
5879
+ else if (pathname === '/api/terminal/rename' && method === 'POST') {
5880
+ await handlePostWebTerminalRename(req, res);
5881
+ }
3717
5882
  else if (pathname === '/api/terminal/stop' && method === 'POST') {
3718
5883
  await handlePostWebTerminalStop(req, res);
3719
5884
  }
@@ -3770,6 +5935,54 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3770
5935
  else if (pathname === '/api/admin/license' && method === 'GET') {
3771
5936
  handleGetAdminLicense(req, res);
3772
5937
  }
5938
+ else if (pathname === '/api/explorer/experiments' && method === 'GET') {
5939
+ handleGetExplorerExperiments(reqUrl, res);
5940
+ }
5941
+ else if (pathname === '/api/explorer/overview' && method === 'GET') {
5942
+ handleGetExplorerOverview(reqUrl, res);
5943
+ }
5944
+ else if (pathname === '/api/explorer/tree' && method === 'GET') {
5945
+ handleGetExplorerTree(reqUrl, res);
5946
+ }
5947
+ else if (pathname === '/api/explorer/runs' && method === 'GET') {
5948
+ handleGetExplorerRuns(reqUrl, res);
5949
+ }
5950
+ else if (pathname === '/api/explorer/run' && method === 'GET') {
5951
+ handleGetExplorerRun(reqUrl, res);
5952
+ }
5953
+ else if (pathname === '/api/explorer/compare' && method === 'GET') {
5954
+ handleGetExplorerCompare(reqUrl, res);
5955
+ }
5956
+ else if (pathname === '/api/explorer/artifact' && method === 'GET') {
5957
+ handleGetExplorerArtifact(reqUrl, res);
5958
+ }
5959
+ else if (pathname === '/api/explorer/register' && method === 'POST') {
5960
+ await handlePostExplorerRegister(req, res);
5961
+ }
5962
+ else if (pathname === '/api/explorer/init' && method === 'POST') {
5963
+ await handlePostExplorerInit(req, res);
5964
+ }
5965
+ else if (pathname === '/api/explorer/quickstart' && method === 'POST') {
5966
+ await handlePostExplorerQuickstart(req, res);
5967
+ }
5968
+ else if (pathname === '/api/explorer/tick' && method === 'POST') {
5969
+ await handlePostExplorerTick(req, res);
5970
+ }
5971
+ else if (pathname === '/api/explorer/step' && method === 'POST') {
5972
+ await handlePostExplorerTick(req, res);
5973
+ }
5974
+ else if (pathname === '/api/explorer/go' && method === 'POST') {
5975
+ await handlePostExplorerTick(req, res);
5976
+ }
5977
+ else if (pathname === '/api/display/file' && method === 'GET') {
5978
+ handleDisplayFile(reqUrl, res);
5979
+ }
5980
+ else if (pathname === '/api/widgets' && method === 'GET') {
5981
+ handleGetWidgets(res);
5982
+ }
5983
+ else if (pathname === '/api/widgets/clear' && method === 'POST') {
5984
+ handleClearWidgets(res);
5985
+ }
3773
5986
  else if (pathname.startsWith('/fonts/') && method === 'GET') {
3774
5987
  serveFontFile(pathname, res);
3775
5988
  }
@@ -3807,7 +6020,8 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3807
6020
  });
3808
6021
  server.on('upgrade', (req, socket, head) => {
3809
6022
  const reqUrl = new URL(req.url || '/', 'http://localhost');
3810
- if (reqUrl.pathname !== '/api/terminal/ws') {
6023
+ const pathname = reqUrl.pathname;
6024
+ if (pathname !== '/api/terminal/ws' && pathname !== '/api/claude/ws') {
3811
6025
  upgradeBadRequest(socket);
3812
6026
  return;
3813
6027
  }
@@ -3837,8 +6051,18 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3837
6051
  upgradeBadRequest(socket);
3838
6052
  return;
3839
6053
  }
3840
- wsServer.handleUpgrade(req, socket, head, (ws) => {
3841
- wsServer.emit('connection', ws, req);
6054
+ if (pathname === '/api/terminal/ws') {
6055
+ wsServer.handleUpgrade(req, socket, head, (ws) => {
6056
+ wsServer.emit('connection', ws, req);
6057
+ });
6058
+ return;
6059
+ }
6060
+ if (record.agent !== 'claude') {
6061
+ upgradeBadRequest(socket);
6062
+ return;
6063
+ }
6064
+ claudeWsServer.handleUpgrade(req, socket, head, (ws) => {
6065
+ claudeWsServer.emit('connection', ws, req);
3842
6066
  });
3843
6067
  });
3844
6068
  const bindTcp = (nextPort) => {
@@ -3860,51 +6084,105 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3860
6084
  if (started)
3861
6085
  return;
3862
6086
  started = true;
3863
- if (useTcp) {
3864
- const actualPort = server.address()?.port ?? listenPort;
3865
- dashboardQuickLink = `http://localhost:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
3866
- log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
3867
- try {
3868
- writeDashboardLink(dashboardQuickLink);
3869
- }
3870
- catch {
3871
- // Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
3872
- }
3873
- if (shouldAutoOpenUiBrowser(standalone)) {
3874
- autoOpenUiBrowser(dashboardQuickLink);
6087
+ void (async () => {
6088
+ if (prewarmImageOnStartup) {
6089
+ try {
6090
+ const cfg = (0, config_js_1.loadConfig)();
6091
+ const runtimeReady = await prepareRuntimeForWebTerminal(cfg.runtime);
6092
+ if (!runtimeReady.ok) {
6093
+ const firstLine = String(runtimeReady.error || 'Container runtime unavailable.')
6094
+ .split('\n')[0]
6095
+ .trim();
6096
+ log.warn(`Skipping startup image preparation: ${firstLine}`);
6097
+ }
6098
+ else {
6099
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(cfg.runtime);
6100
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
6101
+ const firstLine = String(runtimeCheck.error || 'Container runtime unavailable.')
6102
+ .split('\n')[0]
6103
+ .trim();
6104
+ log.warn(`Skipping startup image preparation: ${firstLine}`);
6105
+ }
6106
+ else {
6107
+ const runtime = runtimeCheck.runtime;
6108
+ const image = cfg.image;
6109
+ let imageExists = false;
6110
+ if (runtime === 'podman') {
6111
+ try {
6112
+ await execFileAsync('podman', ['image', 'exists', image], {
6113
+ timeout: 10_000,
6114
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
6115
+ });
6116
+ imageExists = true;
6117
+ }
6118
+ catch {
6119
+ imageExists = false;
6120
+ }
6121
+ }
6122
+ else {
6123
+ imageExists = (0, fs_1.existsSync)((0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image)));
6124
+ }
6125
+ if (!imageExists) {
6126
+ log.step(`No cached image found for ${image}. Preparing it before opening UI...`);
6127
+ await ensureWebTerminalImageReady(runtime, image);
6128
+ log.success(`Prepared image ${image}.`);
6129
+ }
6130
+ }
6131
+ }
6132
+ }
6133
+ catch (err) {
6134
+ const detail = commandErrorDetail(err);
6135
+ const firstLine = (detail || err?.message || String(err)).split('\n')[0].trim();
6136
+ log.warn(`Startup image preparation failed: ${firstLine}`);
6137
+ }
3875
6138
  }
3876
- }
3877
- else {
3878
- try {
3879
- (0, fs_1.chmodSync)(socketPath, config_js_1.PRIVATE_FILE_MODE);
6139
+ if (useTcp) {
6140
+ const actualPort = server.address()?.port ?? listenPort;
6141
+ // Use an explicit IPv4 loopback host to avoid `localhost` IPv6 collisions on macOS.
6142
+ dashboardQuickLink = `http://127.0.0.1:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
6143
+ log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
6144
+ try {
6145
+ writeDashboardLink(dashboardQuickLink);
6146
+ }
6147
+ catch {
6148
+ // Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
6149
+ }
6150
+ if (shouldAutoOpenUiBrowser(standalone)) {
6151
+ autoOpenUiBrowser(dashboardQuickLink);
6152
+ }
3880
6153
  }
3881
- catch {
3882
- // Best effort on platforms that do not support chmod on sockets.
6154
+ else {
6155
+ try {
6156
+ (0, fs_1.chmodSync)(socketPath, config_js_1.PRIVATE_FILE_MODE);
6157
+ }
6158
+ catch {
6159
+ // Best effort on platforms that do not support chmod on sockets.
6160
+ }
6161
+ log.step(`Settings socket: ${socketPath}`);
6162
+ log.step('Use `labgate ui` for browser access on localhost (default port: 7700).');
3883
6163
  }
3884
- log.step(`Settings socket: ${socketPath}`);
3885
- log.step('Use `labgate ui` for browser access on localhost (default port: 7700).');
3886
- }
3887
- if (standalone) {
3888
- log.step('Press Ctrl+C to stop');
3889
- }
3890
- // Initialise SLURM tracking if enabled
3891
- const slurmConfig = (0, config_js_1.loadConfig)();
3892
- if (slurmConfig.slurm.enabled && !slurmDB) {
3893
- try {
3894
- slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
3895
- slurmPoller = new slurm_poller_js_1.SlurmPoller({
3896
- db: slurmDB,
3897
- pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
3898
- sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
3899
- });
3900
- slurmPoller.start();
3901
- log.step('SLURM job tracking enabled');
6164
+ if (standalone) {
6165
+ log.step('Press Ctrl+C to stop');
3902
6166
  }
3903
- catch (err) {
3904
- log.warn(`SLURM tracking unavailable: ${err.message}`);
6167
+ // Initialise SLURM tracking if enabled
6168
+ const slurmConfig = (0, config_js_1.loadConfig)();
6169
+ if (slurmConfig.slurm.enabled && !slurmDB) {
6170
+ try {
6171
+ slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
6172
+ slurmPoller = new slurm_poller_js_1.SlurmPoller({
6173
+ db: slurmDB,
6174
+ pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
6175
+ sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
6176
+ });
6177
+ slurmPoller.start();
6178
+ log.step('SLURM job tracking enabled');
6179
+ }
6180
+ catch (err) {
6181
+ log.warn(`SLURM tracking unavailable: ${err.message}`);
6182
+ }
3905
6183
  }
3906
- }
3907
- startSSEBroadcast();
6184
+ startSSEBroadcast();
6185
+ })();
3908
6186
  });
3909
6187
  server.on('close', () => {
3910
6188
  if (dashboardQuickLink)
@@ -3920,6 +6198,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3920
6198
  }
3921
6199
  webTerminalBridges.clear();
3922
6200
  wsServer.close();
6201
+ claudeWsServer.close();
3923
6202
  });
3924
6203
  server.on('error', (err) => {
3925
6204
  if (!useTcp && err.code === 'EADDRINUSE') {
@@ -3963,6 +6242,13 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3963
6242
  // Best effort cleanup.
3964
6243
  }
3965
6244
  }
6245
+ if (sseInterval) {
6246
+ clearInterval(sseInterval);
6247
+ sseInterval = null;
6248
+ }
6249
+ sseClients.clear();
6250
+ stopResultsWatcher();
6251
+ stopDisplayWatcher();
3966
6252
  // Cleanup SLURM resources
3967
6253
  if (slurmPoller) {
3968
6254
  slurmPoller.stop();
@@ -3976,6 +6262,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3976
6262
  slurmDB = null;
3977
6263
  }
3978
6264
  resultsStore = null;
6265
+ displayStore = null;
3979
6266
  });
3980
6267
  return server;
3981
6268
  }