labgate 0.5.29 → 0.5.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/ui.js CHANGED
@@ -43,7 +43,9 @@ const util_1 = require("util");
43
43
  const crypto_1 = require("crypto");
44
44
  const ws_1 = require("ws");
45
45
  const config_js_1 = require("./config.js");
46
+ const init_js_1 = require("./init.js");
46
47
  const container_js_1 = require("./container.js");
48
+ const runtime_js_1 = require("./runtime.js");
47
49
  const audit_js_1 = require("./audit.js");
48
50
  const slurm_db_js_1 = require("./slurm-db.js");
49
51
  const slurm_poller_js_1 = require("./slurm-poller.js");
@@ -68,6 +70,8 @@ const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
68
70
  const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
69
71
  const IRIS_SAMPLE_DATASET_NAME = 'flowers-iris';
70
72
  const IRIS_SAMPLE_SOURCE_URL = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv';
73
+ const PODMAN_SETUP_TIMEOUT_MS = 30 * 60 * 1000;
74
+ const PODMAN_SETUP_MAX_BUFFER = 16 * 1024 * 1024;
71
75
  const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
72
76
  '\n' +
73
77
  'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
@@ -86,6 +90,26 @@ let slurmPoller = null;
86
90
  let resultsStore = null;
87
91
  const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
88
92
  const webTerminalBridges = new Map();
93
+ const WEB_TERMINAL_INIT_RETENTION_MS = 60 * 60 * 1000;
94
+ const WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS = 60 * 60 * 1000;
95
+ const WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER = 64 * 1024 * 1024;
96
+ const WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS = 30 * 60 * 1000;
97
+ const WEB_TERMINAL_AGENT_PREP_MAX_BUFFER = 32 * 1024 * 1024;
98
+ const WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS = 90 * 1000;
99
+ const WEB_TERMINAL_STARTUP_READY_POLL_MS = 120;
100
+ const WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT = 64 * 1024;
101
+ const WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS = 750;
102
+ const WEB_TERMINAL_BUFFER_MAX_BYTES = 512_000;
103
+ const WEB_TERMINAL_HISTORY_MAX_BYTES = 8 * 1024 * 1024;
104
+ const WEB_TERMINAL_HISTORY_CHUNK_BYTES = 8 * 1024;
105
+ const WEB_TERMINAL_HISTORY_PAGE_DEFAULT = 120;
106
+ const WEB_TERMINAL_HISTORY_PAGE_MAX = 600;
107
+ const WEB_TERMINAL_STARTUP_HEADER_RE = /(?:^|\n)\s*LabGate\s*(?:\n|$)/i;
108
+ const WEB_TERMINAL_STARTUP_BLOCKED_RE = /(?:^|\n)\s*Blocked\s+\d+\s+patterns\b/i;
109
+ const CLAUDE_HEADLESS_STDERR_LIMIT = 12_000;
110
+ const webTerminalInitJobs = new Map();
111
+ const webTerminalImagePullLocks = new Map();
112
+ const webTerminalAgentPrepLocks = new Map();
89
113
  function getResultsStore() {
90
114
  if (!resultsStore) {
91
115
  resultsStore = new results_store_js_1.ResultsStore((0, config_js_1.getResultsDbPath)());
@@ -136,6 +160,475 @@ function getSlurmRuntimeStatus() {
136
160
  missingCommands,
137
161
  };
138
162
  }
163
+ function commandErrorDetail(err) {
164
+ return [
165
+ err?.stderr,
166
+ err?.stdout,
167
+ err?.message,
168
+ err?.cause?.stderr,
169
+ err?.cause?.stdout,
170
+ err?.cause?.message,
171
+ ]
172
+ .filter((part) => typeof part === 'string' && part.trim().length > 0)
173
+ .map((part) => String(part).trim())
174
+ .join('\n');
175
+ }
176
+ function isPodmanNotReadyError(error) {
177
+ return /podman is installed but not ready/i.test(error || '');
178
+ }
179
+ async function prepareRuntimeForWebTerminal(preferred) {
180
+ const initial = (0, runtime_js_1.checkRuntime)(preferred);
181
+ if (initial.ok) {
182
+ return { ok: true, initialized: false };
183
+ }
184
+ const canAutoSetupPodman = ((0, os_1.platform)() === 'darwin' &&
185
+ preferred !== 'apptainer' &&
186
+ hasCommandInPath('podman') &&
187
+ isPodmanNotReadyError(initial.error));
188
+ if (!canAutoSetupPodman) {
189
+ return {
190
+ ok: false,
191
+ initialized: false,
192
+ error: initial.error || 'Container runtime unavailable.',
193
+ };
194
+ }
195
+ log.step('Podman runtime not ready. Attempting automatic machine setup for UI launch...');
196
+ try {
197
+ try {
198
+ await execFileAsync('podman', ['machine', 'init'], {
199
+ timeout: PODMAN_SETUP_TIMEOUT_MS,
200
+ maxBuffer: PODMAN_SETUP_MAX_BUFFER,
201
+ });
202
+ }
203
+ catch (err) {
204
+ const detail = commandErrorDetail(err);
205
+ if (!/already exists/i.test(detail)) {
206
+ throw err;
207
+ }
208
+ }
209
+ await execFileAsync('podman', ['machine', 'start'], {
210
+ timeout: PODMAN_SETUP_TIMEOUT_MS,
211
+ maxBuffer: PODMAN_SETUP_MAX_BUFFER,
212
+ });
213
+ }
214
+ catch (err) {
215
+ const detail = commandErrorDetail(err);
216
+ return {
217
+ ok: false,
218
+ initialized: true,
219
+ error: [
220
+ 'Podman setup failed during UI session launch.',
221
+ detail || 'Unknown Podman error.',
222
+ '',
223
+ 'Try in a terminal:',
224
+ ' podman machine init',
225
+ ' podman machine start',
226
+ ].join('\n'),
227
+ };
228
+ }
229
+ const after = (0, runtime_js_1.checkRuntime)(preferred);
230
+ if (!after.ok) {
231
+ return {
232
+ ok: false,
233
+ initialized: true,
234
+ error: after.error || 'Container runtime unavailable after setup.',
235
+ };
236
+ }
237
+ return { ok: true, initialized: true };
238
+ }
239
+ function createWebTerminalInitId() {
240
+ return `wti-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
241
+ }
242
+ function createWebTerminalInitJob(agent, workdir) {
243
+ const now = new Date().toISOString();
244
+ return {
245
+ id: createWebTerminalInitId(),
246
+ agent,
247
+ workdir,
248
+ status: 'running',
249
+ stage: 'queued',
250
+ message: 'Queued session initialization.',
251
+ startedAt: now,
252
+ updatedAt: now,
253
+ session: null,
254
+ error: null,
255
+ code: null,
256
+ phase: null,
257
+ initialized: false,
258
+ };
259
+ }
260
+ function pruneWebTerminalInitJobs() {
261
+ const now = Date.now();
262
+ for (const [id, job] of webTerminalInitJobs.entries()) {
263
+ if (job.status === 'running')
264
+ continue;
265
+ const ageMs = now - Date.parse(job.updatedAt || job.startedAt || '');
266
+ if (Number.isFinite(ageMs) && ageMs > WEB_TERMINAL_INIT_RETENTION_MS) {
267
+ webTerminalInitJobs.delete(id);
268
+ }
269
+ }
270
+ }
271
+ function updateWebTerminalInitJob(id, patch) {
272
+ const existing = webTerminalInitJobs.get(id);
273
+ if (!existing)
274
+ return null;
275
+ const updated = {
276
+ ...existing,
277
+ ...patch,
278
+ updatedAt: new Date().toISOString(),
279
+ };
280
+ webTerminalInitJobs.set(id, updated);
281
+ return updated;
282
+ }
283
+ function serializeWebTerminalInitJob(job) {
284
+ return {
285
+ id: job.id,
286
+ agent: job.agent,
287
+ workdir: job.workdir,
288
+ status: job.status,
289
+ stage: job.stage,
290
+ message: job.message,
291
+ startedAt: job.startedAt,
292
+ updatedAt: job.updatedAt,
293
+ session: job.session,
294
+ error: job.error,
295
+ code: job.code,
296
+ phase: job.phase,
297
+ initialized: job.initialized,
298
+ };
299
+ }
300
+ async function withWebTerminalImagePullLock(lockKey, work) {
301
+ const existing = webTerminalImagePullLocks.get(lockKey);
302
+ if (existing) {
303
+ await existing;
304
+ return;
305
+ }
306
+ let current = null;
307
+ current = (async () => {
308
+ try {
309
+ await work();
310
+ }
311
+ finally {
312
+ if (current && webTerminalImagePullLocks.get(lockKey) === current) {
313
+ webTerminalImagePullLocks.delete(lockKey);
314
+ }
315
+ }
316
+ })();
317
+ webTerminalImagePullLocks.set(lockKey, current);
318
+ await current;
319
+ }
320
+ async function ensureWebTerminalImageReady(runtime, image, onProgress) {
321
+ onProgress?.('image_check', `Checking image availability for ${image}...`);
322
+ if (runtime === 'podman') {
323
+ const imageExists = async () => {
324
+ try {
325
+ await execFileAsync('podman', ['image', 'exists', image], {
326
+ timeout: 10_000,
327
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
328
+ });
329
+ return true;
330
+ }
331
+ catch {
332
+ return false;
333
+ }
334
+ };
335
+ if (await imageExists())
336
+ return;
337
+ await withWebTerminalImagePullLock(`podman:${image}`, async () => {
338
+ if (await imageExists())
339
+ return;
340
+ onProgress?.('image_pull', `Pulling container image ${image}...`);
341
+ await execFileAsync('podman', ['pull', image], {
342
+ timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
343
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
344
+ });
345
+ });
346
+ return;
347
+ }
348
+ const imagesDir = (0, config_js_1.getImagesDir)();
349
+ const sifPath = (0, path_1.join)(imagesDir, (0, container_js_1.imageToSifName)(image));
350
+ if ((0, fs_1.existsSync)(sifPath))
351
+ return;
352
+ await withWebTerminalImagePullLock(`apptainer:${image}`, async () => {
353
+ if ((0, fs_1.existsSync)(sifPath))
354
+ return;
355
+ (0, fs_1.mkdirSync)(imagesDir, { recursive: true });
356
+ onProgress?.('image_pull', `Pulling container image ${image}...`);
357
+ await execFileAsync('apptainer', ['pull', sifPath, `docker://${image}`], {
358
+ timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
359
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
360
+ });
361
+ });
362
+ }
363
+ function getWebTerminalAgentBootstrapSpec(agent) {
364
+ if (agent === 'codex') {
365
+ return { bin: 'codex', pkg: '@openai/codex' };
366
+ }
367
+ return { bin: 'claude', pkg: '@anthropic-ai/claude-code' };
368
+ }
369
+ async function withWebTerminalAgentPrepareLock(lockKey, work) {
370
+ const existing = webTerminalAgentPrepLocks.get(lockKey);
371
+ if (existing) {
372
+ await existing;
373
+ return;
374
+ }
375
+ let current = null;
376
+ current = (async () => {
377
+ try {
378
+ await work();
379
+ }
380
+ finally {
381
+ if (current && webTerminalAgentPrepLocks.get(lockKey) === current) {
382
+ webTerminalAgentPrepLocks.delete(lockKey);
383
+ }
384
+ }
385
+ })();
386
+ webTerminalAgentPrepLocks.set(lockKey, current);
387
+ await current;
388
+ }
389
+ function getPodmanPrewarmNetworkArgs(networkMode) {
390
+ const mode = String(networkMode || '').trim().toLowerCase();
391
+ if (mode === 'none')
392
+ return ['--network', 'none'];
393
+ if (mode === 'host')
394
+ return ['--network', 'host'];
395
+ return [];
396
+ }
397
+ async function runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script) {
398
+ if (runtime === 'podman') {
399
+ const result = await execFileAsync('podman', [
400
+ 'run',
401
+ '--rm',
402
+ '--workdir', '/work',
403
+ '--volume', `${sandboxHome}:/home/sandbox`,
404
+ '--volume', `${resolvedWorkdir}:/work`,
405
+ '--env', 'HOME=/home/sandbox',
406
+ ...getPodmanPrewarmNetworkArgs(networkMode),
407
+ image,
408
+ 'bash',
409
+ '-lc',
410
+ script,
411
+ ], {
412
+ timeout: WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS,
413
+ maxBuffer: WEB_TERMINAL_AGENT_PREP_MAX_BUFFER,
414
+ });
415
+ return {
416
+ stdout: String(result?.stdout || ''),
417
+ stderr: String(result?.stderr || ''),
418
+ };
419
+ }
420
+ const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image));
421
+ const result = await execFileAsync('apptainer', [
422
+ 'exec',
423
+ '--containall',
424
+ '--cleanenv',
425
+ '--home', `${sandboxHome}:/home/sandbox`,
426
+ '--bind', `${resolvedWorkdir}:/work`,
427
+ '--pwd', '/work',
428
+ sifPath,
429
+ 'bash',
430
+ '-lc',
431
+ script,
432
+ ], {
433
+ timeout: WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS,
434
+ maxBuffer: WEB_TERMINAL_AGENT_PREP_MAX_BUFFER,
435
+ });
436
+ return {
437
+ stdout: String(result?.stdout || ''),
438
+ stderr: String(result?.stderr || ''),
439
+ };
440
+ }
441
+ async function ensureWebTerminalAgentReady(runtime, image, agent, resolvedWorkdir, networkMode, onProgress) {
442
+ const spec = getWebTerminalAgentBootstrapSpec(agent);
443
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
444
+ const installedBin = (0, path_1.join)(sandboxHome, '.npm-global', 'bin', spec.bin);
445
+ if ((0, fs_1.existsSync)(installedBin)) {
446
+ return;
447
+ }
448
+ await withWebTerminalAgentPrepareLock(spec.bin, async () => {
449
+ if ((0, fs_1.existsSync)(installedBin))
450
+ return;
451
+ onProgress?.('agent_prepare', `Preparing ${agent} CLI in sandbox home...`);
452
+ const script = [
453
+ 'set -euo pipefail',
454
+ 'export HOME=/home/sandbox',
455
+ 'mkdir -p "$HOME/.npm-global"',
456
+ 'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
457
+ 'export PATH="$HOME/.npm-global/bin:$PATH"',
458
+ `if ! command -v ${spec.bin} >/dev/null 2>&1; then npm i -g ${spec.pkg}; fi`,
459
+ ].join('\n');
460
+ await runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script);
461
+ });
462
+ }
463
+ function extractAgentVersionFromOutput(output) {
464
+ const marker = String(output || '')
465
+ .split('\n')
466
+ .map((line) => line.trim())
467
+ .find((line) => line.startsWith('LABGATE_AGENT_VERSION:'));
468
+ if (!marker)
469
+ return null;
470
+ const raw = marker.slice('LABGATE_AGENT_VERSION:'.length).trim();
471
+ if (!raw)
472
+ return null;
473
+ return raw.replace(/^v/, '');
474
+ }
475
+ async function updateWebTerminalAgentCli(runtime, image, agent, resolvedWorkdir, networkMode) {
476
+ const spec = getWebTerminalAgentBootstrapSpec(agent);
477
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
478
+ let version = null;
479
+ await withWebTerminalAgentPrepareLock(spec.bin, async () => {
480
+ const script = [
481
+ 'set -euo pipefail',
482
+ 'export HOME=/home/sandbox',
483
+ 'mkdir -p "$HOME/.npm-global"',
484
+ 'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
485
+ 'export PATH="$HOME/.npm-global/bin:$PATH"',
486
+ `npm i -g "${spec.pkg}"`,
487
+ `_labgate_ver="$(${spec.bin} --version 2>/dev/null || true)"`,
488
+ '_labgate_ver="$(printf "%s" "$_labgate_ver" | head -n 1 | tr -d \'\\r\')"',
489
+ 'echo "LABGATE_AGENT_VERSION:${_labgate_ver}"',
490
+ ].join('\n');
491
+ const result = await runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script);
492
+ version = extractAgentVersionFromOutput([result.stdout, result.stderr].filter(Boolean).join('\n'));
493
+ });
494
+ return { version };
495
+ }
496
+ function toRuntimeUnavailableResult(runtimeReady) {
497
+ return {
498
+ ok: false,
499
+ status: runtimeReady.initialized ? 502 : 503,
500
+ body: {
501
+ ok: false,
502
+ code: 'runtime_unavailable',
503
+ phase: 'runtime_setup',
504
+ initialized: runtimeReady.initialized,
505
+ error: runtimeReady.error || 'Container runtime unavailable.',
506
+ },
507
+ };
508
+ }
509
+ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
510
+ const onProgress = opts.onProgress;
511
+ const config = (0, config_js_1.loadConfig)();
512
+ onProgress?.('runtime_setup', 'Checking container runtime...');
513
+ const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
514
+ if (!runtimeReady.ok) {
515
+ return toRuntimeUnavailableResult(runtimeReady);
516
+ }
517
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
518
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
519
+ return {
520
+ ok: false,
521
+ status: runtimeReady.initialized ? 502 : 503,
522
+ body: {
523
+ ok: false,
524
+ code: 'runtime_unavailable',
525
+ phase: 'runtime_setup',
526
+ initialized: runtimeReady.initialized,
527
+ error: runtimeCheck.error || 'Container runtime unavailable.',
528
+ },
529
+ };
530
+ }
531
+ // Preflight tmux before any slow image/agent preparation to avoid unnecessary side effects.
532
+ const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
533
+ if (!tmuxAvailable.ok) {
534
+ return {
535
+ ok: false,
536
+ status: 500,
537
+ body: { ok: false, error: tmuxAvailable.error },
538
+ };
539
+ }
540
+ if (opts.prewarmImage) {
541
+ try {
542
+ await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, onProgress);
543
+ }
544
+ catch (err) {
545
+ const detail = commandErrorDetail(err);
546
+ return {
547
+ ok: false,
548
+ status: 502,
549
+ body: {
550
+ ok: false,
551
+ code: 'image_prepare_failed',
552
+ phase: 'image_prepare',
553
+ runtime: runtimeCheck.runtime,
554
+ image: config.image,
555
+ error: detail || `Failed to pull image ${config.image}.`,
556
+ },
557
+ };
558
+ }
559
+ }
560
+ if (opts.prewarmAgent) {
561
+ try {
562
+ await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, agent, resolvedWorkdir, config.network.mode, onProgress);
563
+ }
564
+ catch (err) {
565
+ const detail = commandErrorDetail(err);
566
+ return {
567
+ ok: false,
568
+ status: 502,
569
+ body: {
570
+ ok: false,
571
+ code: 'agent_prepare_failed',
572
+ phase: 'agent_prepare',
573
+ runtime: runtimeCheck.runtime,
574
+ agent,
575
+ image: config.image,
576
+ error: detail || `Failed to prepare ${agent} in sandbox home.`,
577
+ },
578
+ };
579
+ }
580
+ }
581
+ onProgress?.('tmux_check', 'Checking terminal multiplexer availability...');
582
+ const cliEntrypoint = resolveCliEntrypoint();
583
+ const id = `wt-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
584
+ const record = (0, web_terminal_js_1.createWebTerminalRecord)({
585
+ id,
586
+ agent,
587
+ runtime: runtimeCheck.runtime,
588
+ workdir: resolvedWorkdir,
589
+ });
590
+ (0, web_terminal_js_1.writeWebTerminalRecord)(record);
591
+ onProgress?.('session_start', `Starting ${agent} terminal session...`);
592
+ try {
593
+ await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint);
594
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
595
+ }
596
+ catch (err) {
597
+ const message = err?.message ?? String(err);
598
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
599
+ return {
600
+ ok: false,
601
+ status: 500,
602
+ body: { ok: false, error: `Could not start tmux session: ${message}` },
603
+ };
604
+ }
605
+ onProgress?.('session_start', 'Attaching terminal bridge...');
606
+ const bridge = await ensureWebTerminalBridge(record);
607
+ if (!bridge) {
608
+ try {
609
+ await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
610
+ }
611
+ catch { /* best effort */ }
612
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, {
613
+ status: 'failed',
614
+ exitCode: 1,
615
+ error: 'node-pty bridge unavailable',
616
+ });
617
+ return {
618
+ ok: false,
619
+ status: 500,
620
+ body: {
621
+ ok: false,
622
+ error: 'Started tmux session but could not create terminal bridge (node-pty unavailable).',
623
+ },
624
+ };
625
+ }
626
+ await waitForWebTerminalStartupSummary(record, bridge, onProgress);
627
+ return {
628
+ ok: true,
629
+ session: serializeWebTerminalSession(record),
630
+ };
631
+ }
139
632
  function readBody(req) {
140
633
  return new Promise((resolve, reject) => {
141
634
  const chunks = [];
@@ -419,7 +912,9 @@ function normalizeWebTerminalAgent(raw) {
419
912
  function serializeWebTerminalSession(record) {
420
913
  return {
421
914
  id: record.id,
915
+ name: record.name || '',
422
916
  agent: record.agent,
917
+ runtime: record.runtime || '',
423
918
  workdir: record.workdir,
424
919
  node: record.node,
425
920
  tmuxSession: record.tmuxSession,
@@ -440,11 +935,62 @@ async function loadNodePtyModule() {
440
935
  }
441
936
  }
442
937
  function appendWebTerminalBuffer(bridge, chunk) {
938
+ if (!chunk)
939
+ return [];
443
940
  bridge.buffer += chunk;
444
941
  // Keep recent output bounded to avoid unbounded memory growth.
445
- if (bridge.buffer.length > 512_000) {
446
- bridge.buffer = bridge.buffer.slice(bridge.buffer.length - 512_000);
942
+ if (bridge.buffer.length > WEB_TERMINAL_BUFFER_MAX_BYTES) {
943
+ bridge.buffer = bridge.buffer.slice(bridge.buffer.length - WEB_TERMINAL_BUFFER_MAX_BYTES);
944
+ }
945
+ const appended = [];
946
+ for (let i = 0; i < chunk.length; i += WEB_TERMINAL_HISTORY_CHUNK_BYTES) {
947
+ const piece = chunk.slice(i, i + WEB_TERMINAL_HISTORY_CHUNK_BYTES);
948
+ if (!piece)
949
+ continue;
950
+ const seq = bridge.nextSeq++;
951
+ const nextChunk = { seq, data: piece };
952
+ bridge.history.push(nextChunk);
953
+ appended.push(nextChunk);
954
+ bridge.historyBytes += piece.length;
955
+ }
956
+ while (bridge.historyBytes > WEB_TERMINAL_HISTORY_MAX_BYTES && bridge.history.length > 0) {
957
+ const removed = bridge.history.shift();
958
+ if (!removed)
959
+ break;
960
+ bridge.historyBytes = Math.max(0, bridge.historyBytes - removed.data.length);
447
961
  }
962
+ return appended;
963
+ }
964
+ function getWebTerminalHistoryPage(bridge, options) {
965
+ const history = bridge.history;
966
+ if (!history.length) {
967
+ return {
968
+ chunks: [],
969
+ hasMore: false,
970
+ nextBefore: null,
971
+ oldestSeq: null,
972
+ latestSeq: null,
973
+ };
974
+ }
975
+ const beforeSeq = options?.beforeSeq ?? null;
976
+ const rawLimit = options?.limit ?? WEB_TERMINAL_HISTORY_PAGE_DEFAULT;
977
+ const limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(rawLimit)));
978
+ let endIndex = history.length;
979
+ if (beforeSeq !== null && Number.isFinite(beforeSeq)) {
980
+ const idx = history.findIndex((chunk) => chunk.seq >= beforeSeq);
981
+ endIndex = idx >= 0 ? idx : history.length;
982
+ }
983
+ endIndex = Math.max(0, Math.min(endIndex, history.length));
984
+ const startIndex = Math.max(0, endIndex - limit);
985
+ const chunks = history.slice(startIndex, endIndex);
986
+ const hasMore = startIndex > 0;
987
+ return {
988
+ chunks,
989
+ hasMore,
990
+ nextBefore: hasMore && chunks.length > 0 ? chunks[0].seq : null,
991
+ oldestSeq: history[0]?.seq ?? null,
992
+ latestSeq: history[history.length - 1]?.seq ?? null,
993
+ };
448
994
  }
449
995
  function sendWebTerminalMessage(ws, payload) {
450
996
  if (ws.readyState !== ws_1.WebSocket.OPEN)
@@ -456,6 +1002,140 @@ function sendWebTerminalMessage(ws, payload) {
456
1002
  // Best effort.
457
1003
  }
458
1004
  }
1005
+ function parseJsonObjectLine(line) {
1006
+ try {
1007
+ const parsed = JSON.parse(line);
1008
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1009
+ return null;
1010
+ return parsed;
1011
+ }
1012
+ catch {
1013
+ return null;
1014
+ }
1015
+ }
1016
+ function readRecordString(record, key) {
1017
+ const value = record[key];
1018
+ return typeof value === 'string' ? value : '';
1019
+ }
1020
+ function collectClaudeTextFromContent(content) {
1021
+ if (!Array.isArray(content))
1022
+ return '';
1023
+ let text = '';
1024
+ for (const part of content) {
1025
+ if (!part || typeof part !== 'object' || Array.isArray(part))
1026
+ continue;
1027
+ const node = part;
1028
+ if (node.type === 'text' && typeof node.text === 'string') {
1029
+ text += node.text;
1030
+ }
1031
+ }
1032
+ return text;
1033
+ }
1034
+ function extractClaudeStreamSessionId(event) {
1035
+ const direct = readRecordString(event, 'session_id').trim();
1036
+ if (direct)
1037
+ return direct;
1038
+ const message = event.message;
1039
+ if (!message || typeof message !== 'object' || Array.isArray(message))
1040
+ return '';
1041
+ const nested = readRecordString(message, 'session_id').trim();
1042
+ return nested || '';
1043
+ }
1044
+ function extractClaudeAssistantSnapshot(event) {
1045
+ const type = readRecordString(event, 'type').trim().toLowerCase();
1046
+ if (type === 'assistant') {
1047
+ const message = event.message;
1048
+ if (message && typeof message === 'object' && !Array.isArray(message)) {
1049
+ const content = message.content;
1050
+ const contentText = collectClaudeTextFromContent(content);
1051
+ if (contentText)
1052
+ return contentText;
1053
+ }
1054
+ }
1055
+ if (type === 'result') {
1056
+ const resultText = readRecordString(event, 'result');
1057
+ if (resultText)
1058
+ return resultText;
1059
+ }
1060
+ return '';
1061
+ }
1062
+ function isClaudeAuthenticationFailure(event, assistantSnapshot, stderrText) {
1063
+ const packed = JSON.stringify(event).toLowerCase();
1064
+ const message = `${assistantSnapshot}\n${stderrText}`.toLowerCase();
1065
+ const authRe = /oauth token has expired|authentication_error|failed to authenticate|api error:\s*401/i;
1066
+ return authRe.test(packed) || authRe.test(message);
1067
+ }
1068
+ function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSessionId) {
1069
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
1070
+ const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
1071
+ const resume = resumeSessionId.trim();
1072
+ return [
1073
+ 'exec',
1074
+ '--containall',
1075
+ '--cleanenv',
1076
+ '--home', `${sandboxHome}:/home/sandbox`,
1077
+ '--bind', `${workdir}:/work`,
1078
+ '--pwd', '/work',
1079
+ ...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
1080
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1081
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1082
+ const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
1083
+ return ['--bind', bindSpec];
1084
+ }),
1085
+ ...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
1086
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1087
+ const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
1088
+ return ['--bind', bindSpec];
1089
+ }),
1090
+ ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1091
+ '--env', 'HOME=/home/sandbox',
1092
+ '--env', 'ANTHROPIC_API_KEY=',
1093
+ sifPath,
1094
+ '/home/sandbox/.npm-global/bin/claude',
1095
+ '-p',
1096
+ '--verbose',
1097
+ '--output-format',
1098
+ 'stream-json',
1099
+ '--include-partial-messages',
1100
+ ...(resume ? ['--resume', resume] : []),
1101
+ prompt,
1102
+ ];
1103
+ }
1104
+ function sleep(ms) {
1105
+ return new Promise((resolve) => setTimeout(resolve, ms));
1106
+ }
1107
+ function stripAnsiForStartupReadiness(text) {
1108
+ return String(text || '')
1109
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
1110
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
1111
+ .replace(/\r/g, '\n');
1112
+ }
1113
+ function hasWebTerminalStartupSummary(buffer) {
1114
+ const plain = stripAnsiForStartupReadiness(buffer);
1115
+ return WEB_TERMINAL_STARTUP_HEADER_RE.test(plain) && WEB_TERMINAL_STARTUP_BLOCKED_RE.test(plain);
1116
+ }
1117
+ async function waitForWebTerminalStartupSummary(record, bridge, onProgress) {
1118
+ const deadline = Date.now() + WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS;
1119
+ let lastAliveCheck = 0;
1120
+ onProgress?.('session_start', `Finalizing ${record.agent} startup...`);
1121
+ while (Date.now() < deadline) {
1122
+ const recent = bridge.buffer.length > WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT
1123
+ ? bridge.buffer.slice(-WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT)
1124
+ : bridge.buffer;
1125
+ if (hasWebTerminalStartupSummary(recent))
1126
+ return;
1127
+ if (!bridge.pty)
1128
+ return;
1129
+ const now = Date.now();
1130
+ if (now - lastAliveCheck >= WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS) {
1131
+ lastAliveCheck = now;
1132
+ const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
1133
+ if (!alive)
1134
+ return;
1135
+ }
1136
+ await sleep(WEB_TERMINAL_STARTUP_READY_POLL_MS);
1137
+ }
1138
+ }
459
1139
  function broadcastWebTerminalMessage(bridge, payload) {
460
1140
  for (const ws of bridge.clients) {
461
1141
  if (ws.readyState !== ws_1.WebSocket.OPEN) {
@@ -465,6 +1145,23 @@ function broadcastWebTerminalMessage(bridge, payload) {
465
1145
  sendWebTerminalMessage(ws, payload);
466
1146
  }
467
1147
  }
1148
+ function closeWebTerminalBridgeClients(bridge, code = 4001, reason = 'labgate-bridge-detached') {
1149
+ const clients = Array.from(bridge.clients);
1150
+ bridge.clients.clear();
1151
+ for (const ws of clients) {
1152
+ try {
1153
+ ws.close(code, reason);
1154
+ }
1155
+ catch {
1156
+ try {
1157
+ ws.close();
1158
+ }
1159
+ catch {
1160
+ // Best effort.
1161
+ }
1162
+ }
1163
+ }
1164
+ }
468
1165
  async function ensureWebTerminalBridge(record) {
469
1166
  const existing = webTerminalBridges.get(record.id);
470
1167
  if (existing && existing.pty)
@@ -481,6 +1178,13 @@ async function ensureWebTerminalBridge(record) {
481
1178
  log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
482
1179
  return null;
483
1180
  }
1181
+ try {
1182
+ // Keep wheel scrolling intuitive for both new and existing sessions.
1183
+ await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, 'mouse', 'on'], { timeout: 10_000 });
1184
+ }
1185
+ catch {
1186
+ // Best effort only; attach should still proceed.
1187
+ }
484
1188
  const env = {};
485
1189
  for (const [k, v] of Object.entries(process.env)) {
486
1190
  if (v !== undefined)
@@ -513,40 +1217,65 @@ async function ensureWebTerminalBridge(record) {
513
1217
  const bridge = existing || {
514
1218
  id: record.id,
515
1219
  buffer: '',
1220
+ history: [],
1221
+ historyBytes: 0,
1222
+ nextSeq: 1,
1223
+ stopRequested: false,
516
1224
  clients: new Set(),
517
1225
  pty: ptyProcess,
518
1226
  };
1227
+ bridge.stopRequested = false;
519
1228
  bridge.pty = ptyProcess;
520
1229
  webTerminalBridges.set(record.id, bridge);
521
1230
  ptyProcess.onData((data) => {
522
- appendWebTerminalBuffer(bridge, data);
523
- broadcastWebTerminalMessage(bridge, { type: 'data', id: record.id, data });
1231
+ const appended = appendWebTerminalBuffer(bridge, data);
1232
+ for (const chunk of appended) {
1233
+ broadcastWebTerminalMessage(bridge, {
1234
+ type: 'data',
1235
+ id: record.id,
1236
+ data: chunk.data,
1237
+ seqStart: chunk.seq,
1238
+ seqEnd: chunk.seq,
1239
+ });
1240
+ }
524
1241
  });
525
1242
  ptyProcess.onExit(async () => {
1243
+ const stopRequested = bridge.stopRequested;
1244
+ bridge.stopRequested = false;
526
1245
  bridge.pty = null;
527
1246
  const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
528
- if (!alive) {
529
- const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
530
- const finalCode = exitInfo?.exitCode ?? (record.exitCode ?? 0);
531
- const finalStatus = finalCode === 0 ? 'exited' : 'failed';
532
- const updated = (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, {
533
- status: finalStatus,
534
- exitCode: finalCode,
535
- error: finalCode === 0 ? null : (record.error || `Exited with code ${finalCode}`),
536
- });
537
- broadcastWebTerminalMessage(bridge, {
538
- type: 'status',
539
- id: record.id,
540
- status: updated?.status ?? finalStatus,
541
- exitCode: updated?.exitCode ?? finalCode,
542
- });
1247
+ if (alive) {
1248
+ if (stopRequested)
1249
+ return;
1250
+ // Another tmux client (for example `labgate continue`) may have force-detached this bridge.
1251
+ // Clear stale alternate-screen data and require clients to reconnect for a clean reattach.
1252
+ bridge.buffer = '';
1253
+ bridge.history = [];
1254
+ bridge.historyBytes = 0;
1255
+ closeWebTerminalBridgeClients(bridge);
1256
+ return;
543
1257
  }
1258
+ const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
1259
+ const finalCode = exitInfo?.exitCode ?? (record.exitCode ?? 0);
1260
+ const finalStatus = finalCode === 0 ? 'exited' : 'failed';
1261
+ const updated = (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, {
1262
+ status: finalStatus,
1263
+ exitCode: finalCode,
1264
+ error: finalCode === 0 ? null : (record.error || `Exited with code ${finalCode}`),
1265
+ });
1266
+ broadcastWebTerminalMessage(bridge, {
1267
+ type: 'status',
1268
+ id: record.id,
1269
+ status: updated?.status ?? finalStatus,
1270
+ exitCode: updated?.exitCode ?? finalCode,
1271
+ });
544
1272
  });
545
1273
  return bridge;
546
1274
  }
547
1275
  function stopWebTerminalBridge(bridge) {
548
1276
  if (!bridge.pty)
549
1277
  return;
1278
+ bridge.stopRequested = true;
550
1279
  try {
551
1280
  bridge.pty.kill('SIGTERM');
552
1281
  }
@@ -554,6 +1283,192 @@ function stopWebTerminalBridge(bridge) {
554
1283
  // Best effort.
555
1284
  }
556
1285
  }
1286
+ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1287
+ const send = (payload) => {
1288
+ sendWebTerminalMessage(ws, payload);
1289
+ };
1290
+ const trimmedPrompt = prompt.trim();
1291
+ if (!trimmedPrompt) {
1292
+ send({ type: 'error', error: 'prompt is required' });
1293
+ return () => { };
1294
+ }
1295
+ send({ type: 'status', stage: 'runtime_setup', message: 'Checking container runtime...' });
1296
+ const config = (0, config_js_1.loadConfig)();
1297
+ const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
1298
+ if (!runtimeReady.ok) {
1299
+ send({
1300
+ type: 'error',
1301
+ code: 'runtime_unavailable',
1302
+ error: runtimeReady.error || 'Container runtime unavailable.',
1303
+ });
1304
+ return () => { };
1305
+ }
1306
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
1307
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
1308
+ send({
1309
+ type: 'error',
1310
+ code: 'runtime_unavailable',
1311
+ error: runtimeCheck.error || 'Container runtime unavailable.',
1312
+ });
1313
+ return () => { };
1314
+ }
1315
+ if (runtimeCheck.runtime !== 'apptainer') {
1316
+ send({
1317
+ type: 'error',
1318
+ code: 'runtime_unsupported',
1319
+ error: `Headless Claude chat currently supports Apptainer only (detected: ${runtimeCheck.runtime}).`,
1320
+ });
1321
+ return () => { };
1322
+ }
1323
+ try {
1324
+ await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, (stage, message) => {
1325
+ send({ type: 'status', stage, message });
1326
+ });
1327
+ }
1328
+ catch (err) {
1329
+ send({
1330
+ type: 'error',
1331
+ code: 'image_prepare_failed',
1332
+ error: commandErrorDetail(err) || `Failed to prepare image ${config.image}.`,
1333
+ });
1334
+ return () => { };
1335
+ }
1336
+ try {
1337
+ await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, 'claude', record.workdir, config.network.mode, (stage, message) => send({ type: 'status', stage, message }));
1338
+ }
1339
+ catch (err) {
1340
+ send({
1341
+ type: 'error',
1342
+ code: 'agent_prepare_failed',
1343
+ error: commandErrorDetail(err) || 'Failed to prepare Claude CLI in sandbox home.',
1344
+ });
1345
+ return () => { };
1346
+ }
1347
+ const args = buildClaudeHeadlessApptainerArgs(config, record.workdir, trimmedPrompt, resumeSessionId);
1348
+ send({ type: 'status', stage: 'run', message: 'Running Claude in headless mode...' });
1349
+ const child = (0, child_process_1.spawn)('apptainer', args, {
1350
+ cwd: record.workdir,
1351
+ env: process.env,
1352
+ stdio: ['ignore', 'pipe', 'pipe'],
1353
+ });
1354
+ let stdoutBuffer = '';
1355
+ let stderrBuffer = '';
1356
+ let latestClaudeSessionId = resumeSessionId.trim();
1357
+ let emittedAssistantText = '';
1358
+ let doneSent = false;
1359
+ const sendDone = (exitCode) => {
1360
+ if (doneSent)
1361
+ return;
1362
+ doneSent = true;
1363
+ send({
1364
+ type: 'done',
1365
+ exitCode,
1366
+ sessionId: latestClaudeSessionId || null,
1367
+ isError: exitCode !== 0,
1368
+ });
1369
+ try {
1370
+ ws.close();
1371
+ }
1372
+ catch {
1373
+ // Best effort.
1374
+ }
1375
+ };
1376
+ child.stdout.on('data', (chunk) => {
1377
+ stdoutBuffer += chunk.toString('utf-8');
1378
+ while (true) {
1379
+ const idx = stdoutBuffer.indexOf('\n');
1380
+ if (idx < 0)
1381
+ break;
1382
+ const line = stdoutBuffer.slice(0, idx).trim();
1383
+ stdoutBuffer = stdoutBuffer.slice(idx + 1);
1384
+ if (!line)
1385
+ continue;
1386
+ const event = parseJsonObjectLine(line);
1387
+ if (!event)
1388
+ continue;
1389
+ const sessionId = extractClaudeStreamSessionId(event);
1390
+ if (sessionId && sessionId !== latestClaudeSessionId) {
1391
+ latestClaudeSessionId = sessionId;
1392
+ send({ type: 'session', sessionId });
1393
+ }
1394
+ const snapshot = extractClaudeAssistantSnapshot(event);
1395
+ if (snapshot) {
1396
+ let delta = '';
1397
+ if (snapshot.startsWith(emittedAssistantText)) {
1398
+ delta = snapshot.slice(emittedAssistantText.length);
1399
+ emittedAssistantText = snapshot;
1400
+ }
1401
+ else if (!emittedAssistantText.startsWith(snapshot)) {
1402
+ delta = snapshot;
1403
+ emittedAssistantText = snapshot;
1404
+ }
1405
+ if (delta) {
1406
+ send({ type: 'delta', text: delta });
1407
+ }
1408
+ }
1409
+ if (isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
1410
+ send({
1411
+ type: 'auth_required',
1412
+ error: 'Claude authentication is required. Run /login in raw terminal mode to refresh session.',
1413
+ });
1414
+ }
1415
+ }
1416
+ });
1417
+ child.stderr.on('data', (chunk) => {
1418
+ const text = chunk.toString('utf-8');
1419
+ stderrBuffer = (stderrBuffer + text).slice(-CLAUDE_HEADLESS_STDERR_LIMIT);
1420
+ });
1421
+ child.on('error', (err) => {
1422
+ send({
1423
+ type: 'error',
1424
+ code: 'spawn_failed',
1425
+ error: err.message || String(err),
1426
+ });
1427
+ sendDone(1);
1428
+ });
1429
+ child.on('close', (code) => {
1430
+ const remaining = stdoutBuffer.trim();
1431
+ if (remaining) {
1432
+ const event = parseJsonObjectLine(remaining);
1433
+ if (event) {
1434
+ const sessionId = extractClaudeStreamSessionId(event);
1435
+ if (sessionId && sessionId !== latestClaudeSessionId) {
1436
+ latestClaudeSessionId = sessionId;
1437
+ send({ type: 'session', sessionId });
1438
+ }
1439
+ const snapshot = extractClaudeAssistantSnapshot(event);
1440
+ if (snapshot) {
1441
+ if (snapshot.startsWith(emittedAssistantText)) {
1442
+ const delta = snapshot.slice(emittedAssistantText.length);
1443
+ emittedAssistantText = snapshot;
1444
+ if (delta)
1445
+ send({ type: 'delta', text: delta });
1446
+ }
1447
+ else if (!emittedAssistantText.startsWith(snapshot)) {
1448
+ emittedAssistantText = snapshot;
1449
+ send({ type: 'delta', text: snapshot });
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+ const exitCode = typeof code === 'number' ? code : 1;
1455
+ if (exitCode !== 0) {
1456
+ const detail = stderrBuffer.trim();
1457
+ if (detail) {
1458
+ send({ type: 'error', code: 'claude_failed', error: detail });
1459
+ }
1460
+ }
1461
+ sendDone(exitCode);
1462
+ });
1463
+ return () => {
1464
+ try {
1465
+ child.kill('SIGTERM');
1466
+ }
1467
+ catch {
1468
+ // Best effort.
1469
+ }
1470
+ };
1471
+ }
557
1472
  function serveHTML(res) {
558
1473
  try {
559
1474
  const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8').replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN);
@@ -1852,38 +2767,140 @@ async function handleRestartSession(req, res) {
1852
2767
  return;
1853
2768
  }
1854
2769
  try {
1855
- process.kill(data.pid, 'SIGTERM');
2770
+ process.kill(data.pid, 'SIGTERM');
2771
+ }
2772
+ catch {
2773
+ // Process is already gone. Continue with cleanup + relaunch.
2774
+ }
2775
+ await waitForSessionFileRemoval(sessionFile);
2776
+ if ((0, fs_1.existsSync)(sessionFile)) {
2777
+ let stillRunning = false;
2778
+ try {
2779
+ process.kill(data.pid, 0);
2780
+ stillRunning = true;
2781
+ }
2782
+ catch {
2783
+ // Process is gone.
2784
+ }
2785
+ if (stillRunning) {
2786
+ json(res, { ok: false, error: 'Session did not stop in time. Please stop it first and retry.' }, 409);
2787
+ return;
2788
+ }
2789
+ try {
2790
+ (0, fs_1.unlinkSync)(sessionFile);
2791
+ }
2792
+ catch { /* best effort */ }
2793
+ }
2794
+ relaunchSessionDetached(agent, workdir);
2795
+ json(res, { ok: true, restarted: { id, agent, workdir } });
2796
+ }
2797
+ catch (err) {
2798
+ json(res, { ok: false, error: err.message ?? String(err) }, 500);
2799
+ }
2800
+ }
2801
+ async function handlePostWebTerminalStart(req, res) {
2802
+ try {
2803
+ const body = await readBody(req);
2804
+ const parsed = JSON.parse(body || '{}');
2805
+ const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
2806
+ const rawWorkdir = String(parsed.workdir || '').trim();
2807
+ if (!agent) {
2808
+ json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
2809
+ return;
2810
+ }
2811
+ if (!rawWorkdir) {
2812
+ json(res, { ok: false, error: 'workdir is required' }, 400);
2813
+ return;
2814
+ }
2815
+ const resolvedWorkdir = (0, path_1.resolve)(rawWorkdir.replace(/^~/, (0, os_1.homedir)()));
2816
+ if (!(0, fs_1.existsSync)(resolvedWorkdir)) {
2817
+ json(res, { ok: false, error: `workdir does not exist: ${resolvedWorkdir}` }, 400);
2818
+ return;
2819
+ }
2820
+ let st;
2821
+ try {
2822
+ st = (0, fs_1.statSync)(resolvedWorkdir);
1856
2823
  }
1857
2824
  catch {
1858
- // Process is already gone. Continue with cleanup + relaunch.
2825
+ json(res, { ok: false, error: `Could not access workdir: ${resolvedWorkdir}` }, 400);
2826
+ return;
1859
2827
  }
1860
- await waitForSessionFileRemoval(sessionFile);
1861
- if ((0, fs_1.existsSync)(sessionFile)) {
1862
- let stillRunning = false;
1863
- try {
1864
- process.kill(data.pid, 0);
1865
- stillRunning = true;
1866
- }
1867
- catch {
1868
- // Process is gone.
1869
- }
1870
- if (stillRunning) {
1871
- json(res, { ok: false, error: 'Session did not stop in time. Please stop it first and retry.' }, 409);
1872
- return;
1873
- }
1874
- try {
1875
- (0, fs_1.unlinkSync)(sessionFile);
1876
- }
1877
- catch { /* best effort */ }
2828
+ if (!st.isDirectory()) {
2829
+ json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
2830
+ return;
1878
2831
  }
1879
- relaunchSessionDetached(agent, workdir);
1880
- json(res, { ok: true, restarted: { id, agent, workdir } });
2832
+ const result = await startWebTerminalSession(agent, resolvedWorkdir);
2833
+ if (!result.ok) {
2834
+ json(res, result.body, result.status);
2835
+ return;
2836
+ }
2837
+ json(res, { ok: true, session: result.session });
1881
2838
  }
1882
2839
  catch (err) {
1883
- json(res, { ok: false, error: err.message ?? String(err) }, 500);
2840
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
1884
2841
  }
1885
2842
  }
1886
- async function handlePostWebTerminalStart(req, res) {
2843
+ async function runWebTerminalInitJob(id) {
2844
+ const job = webTerminalInitJobs.get(id);
2845
+ if (!job)
2846
+ return;
2847
+ const progress = (stage, message) => {
2848
+ updateWebTerminalInitJob(id, {
2849
+ status: 'running',
2850
+ stage,
2851
+ message,
2852
+ });
2853
+ };
2854
+ try {
2855
+ const result = await startWebTerminalSession(job.agent, job.workdir, {
2856
+ prewarmImage: true,
2857
+ prewarmAgent: true,
2858
+ onProgress: progress,
2859
+ });
2860
+ if (result.ok) {
2861
+ updateWebTerminalInitJob(id, {
2862
+ status: 'ready',
2863
+ stage: 'ready',
2864
+ message: 'Session ready.',
2865
+ session: result.session,
2866
+ error: null,
2867
+ code: null,
2868
+ phase: null,
2869
+ });
2870
+ pruneWebTerminalInitJobs();
2871
+ return;
2872
+ }
2873
+ const body = result.body || {};
2874
+ const errText = String(body.error || 'Session initialization failed.');
2875
+ updateWebTerminalInitJob(id, {
2876
+ status: 'failed',
2877
+ stage: 'failed',
2878
+ message: errText.split('\n')[0] || 'Session initialization failed.',
2879
+ session: null,
2880
+ error: errText,
2881
+ code: typeof body.code === 'string' ? body.code : null,
2882
+ phase: typeof body.phase === 'string' ? body.phase : null,
2883
+ initialized: body.initialized === true,
2884
+ });
2885
+ }
2886
+ catch (err) {
2887
+ const detail = commandErrorDetail(err) || (err?.message ?? String(err));
2888
+ updateWebTerminalInitJob(id, {
2889
+ status: 'failed',
2890
+ stage: 'failed',
2891
+ message: 'Session initialization failed.',
2892
+ session: null,
2893
+ error: detail,
2894
+ code: 'init_failed',
2895
+ phase: 'init',
2896
+ initialized: false,
2897
+ });
2898
+ }
2899
+ finally {
2900
+ pruneWebTerminalInitJobs();
2901
+ }
2902
+ }
2903
+ async function handlePostWebTerminalInit(req, res) {
1887
2904
  try {
1888
2905
  const body = await readBody(req);
1889
2906
  const parsed = JSON.parse(body || '{}');
@@ -1914,55 +2931,107 @@ async function handlePostWebTerminalStart(req, res) {
1914
2931
  json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
1915
2932
  return;
1916
2933
  }
1917
- const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
1918
- if (!tmuxAvailable.ok) {
1919
- json(res, { ok: false, error: tmuxAvailable.error }, 500);
2934
+ pruneWebTerminalInitJobs();
2935
+ const job = createWebTerminalInitJob(agent, resolvedWorkdir);
2936
+ webTerminalInitJobs.set(job.id, job);
2937
+ void runWebTerminalInitJob(job.id);
2938
+ json(res, { ok: true, init: serializeWebTerminalInitJob(job) }, 202);
2939
+ }
2940
+ catch (err) {
2941
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
2942
+ }
2943
+ }
2944
+ async function handlePostWebTerminalAgentUpdate(req, res) {
2945
+ try {
2946
+ const body = await readBody(req);
2947
+ const parsed = JSON.parse(body || '{}');
2948
+ const agent = normalizeWebTerminalAgent(parsed.agent || '');
2949
+ if (!agent) {
2950
+ json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
2951
+ return;
2952
+ }
2953
+ const config = (0, config_js_1.loadConfig)();
2954
+ const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
2955
+ if (!runtimeReady.ok) {
2956
+ json(res, {
2957
+ ok: false,
2958
+ code: 'runtime_unavailable',
2959
+ phase: 'runtime_setup',
2960
+ initialized: runtimeReady.initialized,
2961
+ error: runtimeReady.error || 'Container runtime unavailable.',
2962
+ }, runtimeReady.initialized ? 502 : 503);
2963
+ return;
2964
+ }
2965
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
2966
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
2967
+ json(res, {
2968
+ ok: false,
2969
+ code: 'runtime_unavailable',
2970
+ phase: 'runtime_setup',
2971
+ initialized: runtimeReady.initialized,
2972
+ error: runtimeCheck.error || 'Container runtime unavailable.',
2973
+ }, runtimeReady.initialized ? 502 : 503);
1920
2974
  return;
1921
2975
  }
1922
- const cliEntrypoint = resolveCliEntrypoint();
1923
- const id = `wt-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
1924
- const record = (0, web_terminal_js_1.createWebTerminalRecord)({
1925
- id,
1926
- agent,
1927
- workdir: resolvedWorkdir,
1928
- });
1929
- (0, web_terminal_js_1.writeWebTerminalRecord)(record);
1930
2976
  try {
1931
- await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint);
1932
- (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
2977
+ await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image);
1933
2978
  }
1934
2979
  catch (err) {
1935
- const message = err?.message ?? String(err);
1936
- (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
1937
- json(res, { ok: false, error: `Could not start tmux session: ${message}` }, 500);
2980
+ const detail = commandErrorDetail(err);
2981
+ json(res, {
2982
+ ok: false,
2983
+ code: 'image_prepare_failed',
2984
+ phase: 'image_prepare',
2985
+ runtime: runtimeCheck.runtime,
2986
+ image: config.image,
2987
+ error: detail || `Failed to pull image ${config.image}.`,
2988
+ }, 502);
1938
2989
  return;
1939
2990
  }
1940
- const bridge = await ensureWebTerminalBridge(record);
1941
- if (!bridge) {
1942
- try {
1943
- await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
1944
- }
1945
- catch { /* best effort */ }
1946
- (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, {
1947
- status: 'failed',
1948
- exitCode: 1,
1949
- error: 'node-pty bridge unavailable',
2991
+ try {
2992
+ const updated = await updateWebTerminalAgentCli(runtimeCheck.runtime, config.image, agent, (0, os_1.homedir)(), config.network.mode);
2993
+ const spec = getWebTerminalAgentBootstrapSpec(agent);
2994
+ json(res, {
2995
+ ok: true,
2996
+ agent,
2997
+ pkg: spec.pkg,
2998
+ runtime: runtimeCheck.runtime,
2999
+ image: config.image,
3000
+ version: updated.version,
3001
+ restartNotice: `Restart active ${agent} sessions to use the updated CLI.`,
1950
3002
  });
3003
+ }
3004
+ catch (err) {
3005
+ const detail = commandErrorDetail(err);
1951
3006
  json(res, {
1952
3007
  ok: false,
1953
- error: 'Started tmux session but could not create terminal bridge (node-pty unavailable).',
1954
- }, 500);
1955
- return;
3008
+ code: 'agent_update_failed',
3009
+ phase: 'agent_prepare',
3010
+ agent,
3011
+ runtime: runtimeCheck.runtime,
3012
+ image: config.image,
3013
+ error: detail || `Failed to update ${agent} CLI.`,
3014
+ }, 502);
1956
3015
  }
1957
- json(res, {
1958
- ok: true,
1959
- session: serializeWebTerminalSession(record),
1960
- });
1961
3016
  }
1962
3017
  catch (err) {
1963
3018
  json(res, { ok: false, error: err?.message ?? String(err) }, 500);
1964
3019
  }
1965
3020
  }
3021
+ async function handleGetWebTerminalInit(reqUrl, res) {
3022
+ pruneWebTerminalInitJobs();
3023
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
3024
+ if (!id) {
3025
+ json(res, { ok: false, error: 'id is required' }, 400);
3026
+ return;
3027
+ }
3028
+ const job = webTerminalInitJobs.get(id);
3029
+ if (!job) {
3030
+ json(res, { ok: false, error: 'Initialization job not found' }, 404);
3031
+ return;
3032
+ }
3033
+ json(res, { ok: true, init: serializeWebTerminalInitJob(job) });
3034
+ }
1966
3035
  async function handleGetWebTerminalSessions(res) {
1967
3036
  const records = (0, web_terminal_js_1.listWebTerminalRecords)();
1968
3037
  const localNode = (0, os_1.hostname)();
@@ -1989,6 +3058,82 @@ async function handleGetWebTerminalSessions(res) {
1989
3058
  const sessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
1990
3059
  json(res, { ok: true, sessions });
1991
3060
  }
3061
+ async function handleGetWebTerminalHistory(reqUrl, res) {
3062
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
3063
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
3064
+ json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
3065
+ return;
3066
+ }
3067
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
3068
+ if (!record) {
3069
+ json(res, { ok: false, error: 'Terminal session not found' }, 404);
3070
+ return;
3071
+ }
3072
+ if (record.node !== (0, os_1.hostname)()) {
3073
+ json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
3074
+ return;
3075
+ }
3076
+ const beforeRaw = String(reqUrl.searchParams.get('before') || '').trim();
3077
+ let beforeSeq = null;
3078
+ if (beforeRaw) {
3079
+ const parsedBefore = Number(beforeRaw);
3080
+ if (!Number.isFinite(parsedBefore)) {
3081
+ json(res, { ok: false, error: 'Invalid before sequence number' }, 400);
3082
+ return;
3083
+ }
3084
+ beforeSeq = Math.max(0, Math.floor(parsedBefore));
3085
+ }
3086
+ const limitRaw = String(reqUrl.searchParams.get('limit') || '').trim();
3087
+ let limit = WEB_TERMINAL_HISTORY_PAGE_DEFAULT;
3088
+ if (limitRaw) {
3089
+ const parsedLimit = Number(limitRaw);
3090
+ if (!Number.isFinite(parsedLimit)) {
3091
+ json(res, { ok: false, error: 'Invalid history limit' }, 400);
3092
+ return;
3093
+ }
3094
+ limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(parsedLimit)));
3095
+ }
3096
+ const bridge = await ensureWebTerminalBridge(record);
3097
+ if (!bridge) {
3098
+ json(res, { ok: false, error: 'Could not open terminal bridge' }, 500);
3099
+ return;
3100
+ }
3101
+ const page = getWebTerminalHistoryPage(bridge, { beforeSeq, limit });
3102
+ json(res, {
3103
+ ok: true,
3104
+ id: record.id,
3105
+ history: {
3106
+ ...page,
3107
+ limit,
3108
+ },
3109
+ });
3110
+ }
3111
+ async function handlePostWebTerminalRename(req, res) {
3112
+ try {
3113
+ const body = await readBody(req);
3114
+ const parsed = JSON.parse(body || '{}');
3115
+ const id = String(parsed.id || '').trim();
3116
+ const name = typeof parsed.name === 'string' ? parsed.name.trim() : '';
3117
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
3118
+ json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
3119
+ return;
3120
+ }
3121
+ if (name && !(0, web_terminal_js_1.isValidWebTerminalName)(name)) {
3122
+ json(res, { ok: false, error: 'Invalid session name format' }, 400);
3123
+ return;
3124
+ }
3125
+ const result = (0, web_terminal_js_1.renameWebTerminalRecord)(id, name);
3126
+ if (!result.ok) {
3127
+ const status = result.code === 'name_taken' ? 409 : result.code === 'not_found' ? 404 : 400;
3128
+ json(res, { ok: false, error: result.error, code: result.code }, status);
3129
+ return;
3130
+ }
3131
+ json(res, { ok: true, session: serializeWebTerminalSession(result.record) });
3132
+ }
3133
+ catch (err) {
3134
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
3135
+ }
3136
+ }
1992
3137
  async function handlePostWebTerminalStop(req, res) {
1993
3138
  try {
1994
3139
  const body = await readBody(req);
@@ -2065,8 +3210,15 @@ async function handleValidatePath(req, res) {
2065
3210
  async function handleBrowseDir(req, res) {
2066
3211
  try {
2067
3212
  const body = await readBody(req);
2068
- const { path: rawPath } = JSON.parse(body);
2069
- const resolved = (rawPath && typeof rawPath === 'string' ? rawPath : '~').replace(/^~/, (0, os_1.homedir)());
3213
+ const parsed = JSON.parse(body);
3214
+ const rawPath = typeof parsed.path === 'string' ? parsed.path : '~';
3215
+ const includeFiles = !!parsed.includeFiles;
3216
+ const includeHidden = !!parsed.includeHidden;
3217
+ const rawLimit = Number(parsed.maxEntries);
3218
+ const maxEntries = Number.isFinite(rawLimit)
3219
+ ? Math.max(100, Math.min(5000, Math.floor(rawLimit)))
3220
+ : 2000;
3221
+ const resolved = rawPath.replace(/^~/, (0, os_1.homedir)());
2070
3222
  if (!(0, fs_1.existsSync)(resolved) || !(0, fs_1.statSync)(resolved).isDirectory()) {
2071
3223
  json(res, { ok: false, error: 'Not a directory', path: resolved });
2072
3224
  return;
@@ -2080,19 +3232,41 @@ async function handleBrowseDir(req, res) {
2080
3232
  return;
2081
3233
  }
2082
3234
  const dirs = [];
3235
+ const files = [];
3236
+ let truncated = false;
2083
3237
  for (const entry of entries) {
2084
- if (entry.startsWith('.'))
2085
- continue; // skip dotfiles
3238
+ if (!includeHidden && entry.startsWith('.'))
3239
+ continue; // skip dotfiles by default
2086
3240
  const full = (0, path_1.join)(resolved, entry);
2087
3241
  try {
2088
- if ((0, fs_1.statSync)(full).isDirectory()) {
3242
+ const st = (0, fs_1.statSync)(full);
3243
+ if (st.isDirectory()) {
2089
3244
  dirs.push({ name: entry, path: full });
2090
3245
  }
3246
+ else if (includeFiles && st.isFile()) {
3247
+ files.push({ name: entry, path: full });
3248
+ }
2091
3249
  }
2092
3250
  catch { /* skip inaccessible */ }
3251
+ if ((dirs.length + files.length) >= maxEntries) {
3252
+ truncated = true;
3253
+ break;
3254
+ }
2093
3255
  }
2094
3256
  dirs.sort((a, b) => a.name.localeCompare(b.name));
2095
- json(res, { ok: true, path: resolved, dirs });
3257
+ files.sort((a, b) => a.name.localeCompare(b.name));
3258
+ const entriesOut = [
3259
+ ...dirs.map((d) => ({ name: d.name, path: d.path, type: 'dir' })),
3260
+ ...files.map((f) => ({ name: f.name, path: f.path, type: 'file' })),
3261
+ ];
3262
+ json(res, {
3263
+ ok: true,
3264
+ path: resolved,
3265
+ dirs,
3266
+ files: includeFiles ? files : undefined,
3267
+ entries: includeFiles ? entriesOut : undefined,
3268
+ truncated,
3269
+ });
2096
3270
  }
2097
3271
  catch (err) {
2098
3272
  json(res, { ok: false, error: err.message ?? String(err) }, 400);
@@ -2412,6 +3586,8 @@ function mapContainerPathToHost(path, sandboxHome) {
2412
3586
  return (0, config_js_1.getConfigPath)();
2413
3587
  if (path === '/labgate-config/slurm.db')
2414
3588
  return (0, config_js_1.getSlurmDbPath)();
3589
+ if (path === '/labgate-config/results.json')
3590
+ return (0, config_js_1.getResultsDbPath)();
2415
3591
  return path;
2416
3592
  }
2417
3593
  function readMcpConfigData() {
@@ -2584,7 +3760,7 @@ function collectMcpState() {
2584
3760
  env: resultsEntry?.env || null,
2585
3761
  mcpConfigPath,
2586
3762
  serverPath: resolveServerPathFromEntry(resultsEntry, sandboxHome),
2587
- dbPath: null,
3763
+ dbPath: resolveDbPathFromEntry(resultsEntry, sandboxHome),
2588
3764
  tools: [
2589
3765
  { name: 'list_results', title: 'List Results', description: 'List recorded results with filtering and pagination' },
2590
3766
  { name: 'register_result', title: 'Register Result', description: 'Create a new structured result entry' },
@@ -3167,6 +4343,87 @@ function handleGetSlurmStats(res) {
3167
4343
  }
3168
4344
  // ── SSE: Server-Sent Events for real-time dashboard updates ──
3169
4345
  const sseClients = new Set();
4346
+ const RESULTS_WATCH_DEBOUNCE_MS = 120;
4347
+ let lastResultsSignature = getResultsFileSignature();
4348
+ let resultsWatcher = null;
4349
+ let resultsWatchDebounce = null;
4350
+ function getResultsFileSignature() {
4351
+ const resultsPath = (0, config_js_1.getResultsDbPath)();
4352
+ try {
4353
+ if (!(0, fs_1.existsSync)(resultsPath))
4354
+ return 'missing';
4355
+ const st = (0, fs_1.statSync)(resultsPath);
4356
+ return `${st.size}:${Math.floor(st.mtimeMs)}`;
4357
+ }
4358
+ catch {
4359
+ return 'error';
4360
+ }
4361
+ }
4362
+ function maybeBroadcastResultsChanged() {
4363
+ const signature = getResultsFileSignature();
4364
+ if (signature === lastResultsSignature)
4365
+ return;
4366
+ if (sseClients.size === 0)
4367
+ return;
4368
+ lastResultsSignature = signature;
4369
+ broadcastSSE('results_changed', {
4370
+ changed_at: new Date().toISOString(),
4371
+ signature,
4372
+ });
4373
+ }
4374
+ function scheduleResultsChangeCheck(delayMs = RESULTS_WATCH_DEBOUNCE_MS) {
4375
+ if (resultsWatchDebounce) {
4376
+ clearTimeout(resultsWatchDebounce);
4377
+ }
4378
+ resultsWatchDebounce = setTimeout(() => {
4379
+ resultsWatchDebounce = null;
4380
+ maybeBroadcastResultsChanged();
4381
+ }, delayMs);
4382
+ resultsWatchDebounce.unref?.();
4383
+ }
4384
+ function stopResultsWatcher() {
4385
+ if (resultsWatchDebounce) {
4386
+ clearTimeout(resultsWatchDebounce);
4387
+ resultsWatchDebounce = null;
4388
+ }
4389
+ if (resultsWatcher) {
4390
+ try {
4391
+ resultsWatcher.close();
4392
+ }
4393
+ catch {
4394
+ // Best effort.
4395
+ }
4396
+ resultsWatcher = null;
4397
+ }
4398
+ }
4399
+ function startResultsWatcher() {
4400
+ if (resultsWatcher)
4401
+ return;
4402
+ lastResultsSignature = getResultsFileSignature();
4403
+ const resultsPath = (0, config_js_1.getResultsDbPath)();
4404
+ const watchDir = (0, path_1.dirname)(resultsPath);
4405
+ const watchFile = (0, path_1.basename)(resultsPath);
4406
+ try {
4407
+ (0, config_js_1.ensurePrivateDir)(watchDir);
4408
+ }
4409
+ catch {
4410
+ // Best effort.
4411
+ }
4412
+ try {
4413
+ resultsWatcher = (0, fs_1.watch)(watchDir, (_eventType, filename) => {
4414
+ const changed = filename ? String(filename) : '';
4415
+ if (changed && changed !== watchFile)
4416
+ return;
4417
+ scheduleResultsChangeCheck();
4418
+ });
4419
+ resultsWatcher.on('error', () => {
4420
+ stopResultsWatcher();
4421
+ });
4422
+ }
4423
+ catch {
4424
+ resultsWatcher = null;
4425
+ }
4426
+ }
3170
4427
  function handleSSE(_req, res) {
3171
4428
  res.writeHead(200, {
3172
4429
  'Content-Type': 'text/event-stream',
@@ -3194,6 +4451,7 @@ let sseInterval = null;
3194
4451
  function startSSEBroadcast() {
3195
4452
  if (sseInterval)
3196
4453
  return;
4454
+ startResultsWatcher();
3197
4455
  sseInterval = setInterval(async () => {
3198
4456
  if (sseClients.size === 0)
3199
4457
  return;
@@ -3270,6 +4528,8 @@ function startSSEBroadcast() {
3270
4528
  }
3271
4529
  catch { /* slurm DB unavailable */ }
3272
4530
  }
4531
+ // Results changes may come from external MCP processes; emit only on file mutation.
4532
+ maybeBroadcastResultsChanged();
3273
4533
  }, 2000);
3274
4534
  sseInterval.unref?.();
3275
4535
  }
@@ -3504,10 +4764,12 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3504
4764
  const options = typeof optsOrPort === 'number'
3505
4765
  ? { port: optsOrPort, standalone: standaloneArg }
3506
4766
  : optsOrPort;
4767
+ (0, init_js_1.ensureBundledIrisDatasetRegistration)();
3507
4768
  const standalone = options.standalone ?? true;
3508
4769
  const socketPath = options.socketPath || (0, config_js_1.getUiSocketPath)();
3509
4770
  const tcpPort = Number.isFinite(options.port) ? Math.floor(options.port) : null;
3510
4771
  const useTcp = tcpPort !== null;
4772
+ const prewarmImageOnStartup = options.prewarmImageOnStartup === true;
3511
4773
  const requestedPort = tcpPort ?? 0;
3512
4774
  const maxPort = requestedPort + 3;
3513
4775
  const uiAccessToken = useTcp ? (0, crypto_1.randomBytes)(24).toString('hex') : '';
@@ -3520,9 +4782,16 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3520
4782
  (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(socketPath));
3521
4783
  }
3522
4784
  const wsServer = new ws_1.WebSocketServer({ noServer: true });
4785
+ const claudeWsServer = new ws_1.WebSocketServer({ noServer: true });
3523
4786
  wsServer.on('connection', async (ws, req) => {
3524
4787
  const reqUrl = new URL(req.url || '/', 'http://localhost');
3525
4788
  const id = reqUrl.searchParams.get('id') || '';
4789
+ const replayRaw = String(reqUrl.searchParams.get('replay') || '').trim().toLowerCase();
4790
+ const replayDisabled = replayRaw === '0' || replayRaw === 'false' || replayRaw === 'off';
4791
+ const afterSeqRaw = String(reqUrl.searchParams.get('afterSeq') || '').trim();
4792
+ const parsedAfterSeq = afterSeqRaw ? Number(afterSeqRaw) : NaN;
4793
+ const hasAfterSeq = afterSeqRaw.length > 0 && Number.isFinite(parsedAfterSeq);
4794
+ const afterSeq = hasAfterSeq ? Math.floor(parsedAfterSeq) : null;
3526
4795
  const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
3527
4796
  if (!record) {
3528
4797
  sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found' });
@@ -3543,7 +4812,19 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3543
4812
  exitCode: record.exitCode,
3544
4813
  error: record.error,
3545
4814
  });
3546
- if (bridge.buffer) {
4815
+ if (afterSeq !== null) {
4816
+ const pending = bridge.history.filter((chunk) => chunk.seq > afterSeq);
4817
+ for (const chunk of pending) {
4818
+ sendWebTerminalMessage(ws, {
4819
+ type: 'data',
4820
+ id: record.id,
4821
+ data: chunk.data,
4822
+ seqStart: chunk.seq,
4823
+ seqEnd: chunk.seq,
4824
+ });
4825
+ }
4826
+ }
4827
+ else if (!replayDisabled && bridge.buffer) {
3547
4828
  sendWebTerminalMessage(ws, { type: 'data', id: record.id, data: bridge.buffer });
3548
4829
  }
3549
4830
  ws.on('message', (raw) => {
@@ -3584,6 +4865,65 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3584
4865
  }
3585
4866
  });
3586
4867
  });
4868
+ claudeWsServer.on('connection', (ws, req) => {
4869
+ const reqUrl = new URL(req.url || '/', 'http://localhost');
4870
+ const id = (reqUrl.searchParams.get('id') || '').trim();
4871
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
4872
+ if (!record || record.node !== (0, os_1.hostname)()) {
4873
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found on this node.' });
4874
+ ws.close();
4875
+ return;
4876
+ }
4877
+ if (record.agent !== 'claude') {
4878
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Headless chat is currently supported for Claude sessions only.' });
4879
+ ws.close();
4880
+ return;
4881
+ }
4882
+ let disposeRun = null;
4883
+ let runInFlight = false;
4884
+ ws.on('message', (raw) => {
4885
+ if (runInFlight) {
4886
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Headless Claude run already in progress for this socket.' });
4887
+ return;
4888
+ }
4889
+ let parsed = null;
4890
+ try {
4891
+ parsed = JSON.parse(raw.toString('utf-8'));
4892
+ }
4893
+ catch {
4894
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Invalid request payload.' });
4895
+ return;
4896
+ }
4897
+ if (!parsed || parsed.type !== 'prompt') {
4898
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Unsupported request. Expected { type: "prompt", prompt }.' });
4899
+ return;
4900
+ }
4901
+ const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
4902
+ const resumeSessionId = typeof parsed.resumeSessionId === 'string' ? parsed.resumeSessionId : '';
4903
+ runInFlight = true;
4904
+ void startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId)
4905
+ .then((cleanup) => {
4906
+ disposeRun = cleanup;
4907
+ })
4908
+ .catch((err) => {
4909
+ sendWebTerminalMessage(ws, {
4910
+ type: 'error',
4911
+ code: 'headless_failed',
4912
+ error: err?.message ?? String(err),
4913
+ });
4914
+ sendWebTerminalMessage(ws, { type: 'done', exitCode: 1, isError: true });
4915
+ });
4916
+ });
4917
+ ws.on('close', () => {
4918
+ if (disposeRun) {
4919
+ try {
4920
+ disposeRun();
4921
+ }
4922
+ catch { /* best effort */ }
4923
+ }
4924
+ disposeRun = null;
4925
+ });
4926
+ });
3587
4927
  const server = (0, http_1.createServer)(async (req, res) => {
3588
4928
  const url = req.url ?? '/';
3589
4929
  const reqUrl = new URL(url, 'http://localhost');
@@ -3709,9 +5049,24 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3709
5049
  else if (pathname === '/api/terminal/sessions' && method === 'GET') {
3710
5050
  await handleGetWebTerminalSessions(res);
3711
5051
  }
5052
+ else if (pathname === '/api/terminal/history' && method === 'GET') {
5053
+ await handleGetWebTerminalHistory(reqUrl, res);
5054
+ }
5055
+ else if (pathname === '/api/terminal/init' && method === 'GET') {
5056
+ await handleGetWebTerminalInit(reqUrl, res);
5057
+ }
5058
+ else if (pathname === '/api/terminal/init' && method === 'POST') {
5059
+ await handlePostWebTerminalInit(req, res);
5060
+ }
3712
5061
  else if (pathname === '/api/terminal/start' && method === 'POST') {
3713
5062
  await handlePostWebTerminalStart(req, res);
3714
5063
  }
5064
+ else if (pathname === '/api/terminal/agent/update' && method === 'POST') {
5065
+ await handlePostWebTerminalAgentUpdate(req, res);
5066
+ }
5067
+ else if (pathname === '/api/terminal/rename' && method === 'POST') {
5068
+ await handlePostWebTerminalRename(req, res);
5069
+ }
3715
5070
  else if (pathname === '/api/terminal/stop' && method === 'POST') {
3716
5071
  await handlePostWebTerminalStop(req, res);
3717
5072
  }
@@ -3805,7 +5160,8 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3805
5160
  });
3806
5161
  server.on('upgrade', (req, socket, head) => {
3807
5162
  const reqUrl = new URL(req.url || '/', 'http://localhost');
3808
- if (reqUrl.pathname !== '/api/terminal/ws') {
5163
+ const pathname = reqUrl.pathname;
5164
+ if (pathname !== '/api/terminal/ws' && pathname !== '/api/claude/ws') {
3809
5165
  upgradeBadRequest(socket);
3810
5166
  return;
3811
5167
  }
@@ -3835,13 +5191,24 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3835
5191
  upgradeBadRequest(socket);
3836
5192
  return;
3837
5193
  }
3838
- wsServer.handleUpgrade(req, socket, head, (ws) => {
3839
- wsServer.emit('connection', ws, req);
5194
+ if (pathname === '/api/terminal/ws') {
5195
+ wsServer.handleUpgrade(req, socket, head, (ws) => {
5196
+ wsServer.emit('connection', ws, req);
5197
+ });
5198
+ return;
5199
+ }
5200
+ if (record.agent !== 'claude') {
5201
+ upgradeBadRequest(socket);
5202
+ return;
5203
+ }
5204
+ claudeWsServer.handleUpgrade(req, socket, head, (ws) => {
5205
+ claudeWsServer.emit('connection', ws, req);
3840
5206
  });
3841
5207
  });
3842
5208
  const bindTcp = (nextPort) => {
3843
5209
  listenPort = nextPort;
3844
- server.listen(listenPort, '127.0.0.1');
5210
+ const address = options.listenAddress || '127.0.0.1';
5211
+ server.listen(listenPort, address);
3845
5212
  };
3846
5213
  const bindUnixSocket = () => {
3847
5214
  try {
@@ -3857,51 +5224,104 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3857
5224
  if (started)
3858
5225
  return;
3859
5226
  started = true;
3860
- if (useTcp) {
3861
- const actualPort = server.address()?.port ?? listenPort;
3862
- dashboardQuickLink = `http://localhost:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
3863
- log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
3864
- try {
3865
- writeDashboardLink(dashboardQuickLink);
3866
- }
3867
- catch {
3868
- // Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
3869
- }
3870
- if (shouldAutoOpenUiBrowser(standalone)) {
3871
- autoOpenUiBrowser(dashboardQuickLink);
5227
+ void (async () => {
5228
+ if (prewarmImageOnStartup) {
5229
+ try {
5230
+ const cfg = (0, config_js_1.loadConfig)();
5231
+ const runtimeReady = await prepareRuntimeForWebTerminal(cfg.runtime);
5232
+ if (!runtimeReady.ok) {
5233
+ const firstLine = String(runtimeReady.error || 'Container runtime unavailable.')
5234
+ .split('\n')[0]
5235
+ .trim();
5236
+ log.warn(`Skipping startup image preparation: ${firstLine}`);
5237
+ }
5238
+ else {
5239
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(cfg.runtime);
5240
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
5241
+ const firstLine = String(runtimeCheck.error || 'Container runtime unavailable.')
5242
+ .split('\n')[0]
5243
+ .trim();
5244
+ log.warn(`Skipping startup image preparation: ${firstLine}`);
5245
+ }
5246
+ else {
5247
+ const runtime = runtimeCheck.runtime;
5248
+ const image = cfg.image;
5249
+ let imageExists = false;
5250
+ if (runtime === 'podman') {
5251
+ try {
5252
+ await execFileAsync('podman', ['image', 'exists', image], {
5253
+ timeout: 10_000,
5254
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
5255
+ });
5256
+ imageExists = true;
5257
+ }
5258
+ catch {
5259
+ imageExists = false;
5260
+ }
5261
+ }
5262
+ else {
5263
+ imageExists = (0, fs_1.existsSync)((0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image)));
5264
+ }
5265
+ if (!imageExists) {
5266
+ log.step(`No cached image found for ${image}. Preparing it before opening UI...`);
5267
+ await ensureWebTerminalImageReady(runtime, image);
5268
+ log.success(`Prepared image ${image}.`);
5269
+ }
5270
+ }
5271
+ }
5272
+ }
5273
+ catch (err) {
5274
+ const detail = commandErrorDetail(err);
5275
+ const firstLine = (detail || err?.message || String(err)).split('\n')[0].trim();
5276
+ log.warn(`Startup image preparation failed: ${firstLine}`);
5277
+ }
3872
5278
  }
3873
- }
3874
- else {
3875
- try {
3876
- (0, fs_1.chmodSync)(socketPath, config_js_1.PRIVATE_FILE_MODE);
5279
+ if (useTcp) {
5280
+ const actualPort = server.address()?.port ?? listenPort;
5281
+ dashboardQuickLink = `http://localhost:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
5282
+ log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
5283
+ try {
5284
+ writeDashboardLink(dashboardQuickLink);
5285
+ }
5286
+ catch {
5287
+ // Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
5288
+ }
5289
+ if (shouldAutoOpenUiBrowser(standalone)) {
5290
+ autoOpenUiBrowser(dashboardQuickLink);
5291
+ }
3877
5292
  }
3878
- catch {
3879
- // Best effort on platforms that do not support chmod on sockets.
5293
+ else {
5294
+ try {
5295
+ (0, fs_1.chmodSync)(socketPath, config_js_1.PRIVATE_FILE_MODE);
5296
+ }
5297
+ catch {
5298
+ // Best effort on platforms that do not support chmod on sockets.
5299
+ }
5300
+ log.step(`Settings socket: ${socketPath}`);
5301
+ log.step('Use `labgate ui` for browser access on localhost (default port: 7700).');
3880
5302
  }
3881
- log.step(`Settings socket: ${socketPath}`);
3882
- log.step('Use `labgate ui` for browser access on localhost (default port: 7700).');
3883
- }
3884
- if (standalone) {
3885
- log.step('Press Ctrl+C to stop');
3886
- }
3887
- // Initialise SLURM tracking if enabled
3888
- const slurmConfig = (0, config_js_1.loadConfig)();
3889
- if (slurmConfig.slurm.enabled && !slurmDB) {
3890
- try {
3891
- slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
3892
- slurmPoller = new slurm_poller_js_1.SlurmPoller({
3893
- db: slurmDB,
3894
- pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
3895
- sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
3896
- });
3897
- slurmPoller.start();
3898
- log.step('SLURM job tracking enabled');
5303
+ if (standalone) {
5304
+ log.step('Press Ctrl+C to stop');
3899
5305
  }
3900
- catch (err) {
3901
- log.warn(`SLURM tracking unavailable: ${err.message}`);
5306
+ // Initialise SLURM tracking if enabled
5307
+ const slurmConfig = (0, config_js_1.loadConfig)();
5308
+ if (slurmConfig.slurm.enabled && !slurmDB) {
5309
+ try {
5310
+ slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
5311
+ slurmPoller = new slurm_poller_js_1.SlurmPoller({
5312
+ db: slurmDB,
5313
+ pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
5314
+ sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
5315
+ });
5316
+ slurmPoller.start();
5317
+ log.step('SLURM job tracking enabled');
5318
+ }
5319
+ catch (err) {
5320
+ log.warn(`SLURM tracking unavailable: ${err.message}`);
5321
+ }
3902
5322
  }
3903
- }
3904
- startSSEBroadcast();
5323
+ startSSEBroadcast();
5324
+ })();
3905
5325
  });
3906
5326
  server.on('close', () => {
3907
5327
  if (dashboardQuickLink)
@@ -3917,6 +5337,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3917
5337
  }
3918
5338
  webTerminalBridges.clear();
3919
5339
  wsServer.close();
5340
+ claudeWsServer.close();
3920
5341
  });
3921
5342
  server.on('error', (err) => {
3922
5343
  if (!useTcp && err.code === 'EADDRINUSE') {
@@ -3960,6 +5381,12 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3960
5381
  // Best effort cleanup.
3961
5382
  }
3962
5383
  }
5384
+ if (sseInterval) {
5385
+ clearInterval(sseInterval);
5386
+ sseInterval = null;
5387
+ }
5388
+ sseClients.clear();
5389
+ stopResultsWatcher();
3963
5390
  // Cleanup SLURM resources
3964
5391
  if (slurmPoller) {
3965
5392
  slurmPoller.stop();