labgate 0.5.40 → 0.5.43

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 (51) hide show
  1. package/README.md +132 -265
  2. package/bin/postinstall.js +40 -0
  3. package/dist/cli.js +56 -43
  4. package/dist/cli.js.map +1 -1
  5. package/dist/lib/cli-update-notice.d.ts +13 -0
  6. package/dist/lib/cli-update-notice.js +21 -0
  7. package/dist/lib/cli-update-notice.js.map +1 -0
  8. package/dist/lib/config.d.ts +18 -3
  9. package/dist/lib/config.js +151 -80
  10. package/dist/lib/config.js.map +1 -1
  11. package/dist/lib/container.d.ts +11 -9
  12. package/dist/lib/container.js +753 -302
  13. package/dist/lib/container.js.map +1 -1
  14. package/dist/lib/dataset-mcp.js +2 -9
  15. package/dist/lib/dataset-mcp.js.map +1 -1
  16. package/dist/lib/display-mcp.d.ts +2 -2
  17. package/dist/lib/display-mcp.js +17 -38
  18. package/dist/lib/display-mcp.js.map +1 -1
  19. package/dist/lib/doctor.js +8 -0
  20. package/dist/lib/doctor.js.map +1 -1
  21. package/dist/lib/explorer-claude.js +36 -1
  22. package/dist/lib/explorer-claude.js.map +1 -1
  23. package/dist/lib/explorer-eval.js +3 -2
  24. package/dist/lib/explorer-eval.js.map +1 -1
  25. package/dist/lib/init.js +14 -18
  26. package/dist/lib/init.js.map +1 -1
  27. package/dist/lib/slurm-cli-passthrough.d.ts +12 -2
  28. package/dist/lib/slurm-cli-passthrough.js +401 -143
  29. package/dist/lib/slurm-cli-passthrough.js.map +1 -1
  30. package/dist/lib/startup-stage-lock.d.ts +21 -0
  31. package/dist/lib/startup-stage-lock.js +195 -0
  32. package/dist/lib/startup-stage-lock.js.map +1 -0
  33. package/dist/lib/ui.d.ts +40 -0
  34. package/dist/lib/ui.html +4953 -3366
  35. package/dist/lib/ui.js +1815 -432
  36. package/dist/lib/ui.js.map +1 -1
  37. package/dist/lib/update-check.d.ts +33 -0
  38. package/dist/lib/update-check.js +203 -0
  39. package/dist/lib/update-check.js.map +1 -0
  40. package/dist/lib/web-terminal-startup-readiness.d.ts +8 -0
  41. package/dist/lib/web-terminal-startup-readiness.js +29 -0
  42. package/dist/lib/web-terminal-startup-readiness.js.map +1 -0
  43. package/dist/lib/web-terminal.d.ts +51 -0
  44. package/dist/lib/web-terminal.js +171 -1
  45. package/dist/lib/web-terminal.js.map +1 -1
  46. package/dist/mcp-bundles/dataset-mcp.bundle.mjs +125 -74
  47. package/dist/mcp-bundles/display-mcp.bundle.mjs +22 -30
  48. package/dist/mcp-bundles/explorer-mcp.bundle.mjs +211 -106
  49. package/dist/mcp-bundles/results-mcp.bundle.mjs +22 -24
  50. package/dist/mcp-bundles/slurm-mcp.bundle.mjs +6 -8
  51. package/package.json +4 -3
package/dist/lib/ui.js CHANGED
@@ -33,6 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.waitForWebTerminalStartupSummary = waitForWebTerminalStartupSummary;
37
+ exports.selectTranscriptFileForSession = selectTranscriptFileForSession;
36
38
  exports.startUI = startUI;
37
39
  const http_1 = require("http");
38
40
  const fs_1 = require("fs");
@@ -55,11 +57,13 @@ const display_store_js_1 = require("./display-store.js");
55
57
  const policy_js_1 = require("./policy.js");
56
58
  const license_js_1 = require("./license.js");
57
59
  const web_terminal_js_1 = require("./web-terminal.js");
60
+ const web_terminal_startup_readiness_js_1 = require("./web-terminal-startup-readiness.js");
58
61
  const explorer_js_1 = require("./explorer.js");
59
62
  const explorer_eval_js_1 = require("./explorer-eval.js");
60
63
  const explorer_store_js_1 = require("./explorer-store.js");
61
64
  const feedback_js_1 = require("./feedback.js");
62
65
  const automation_engine_js_1 = require("./automation-engine.js");
66
+ const update_check_js_1 = require("./update-check.js");
63
67
  const log = __importStar(require("./log.js"));
64
68
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
65
69
  const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
@@ -83,7 +87,6 @@ const PODMAN_SETUP_MAX_BUFFER = 16 * 1024 * 1024;
83
87
  const EXPLORER_TSP_TEMPLATE_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'templates', 'tsp-lab');
84
88
  const EXPLORER_TSP_TEMPLATE_SOURCE_REPO = (0, path_1.join)((0, config_js_1.getExplorerRootDir)(), 'templates', 'tsp-lab-source');
85
89
  const EXPLORER_ARTIFACT_READ_MAX_BYTES = 2 * 1024 * 1024;
86
- const UI_VERSION_NPM_PACKAGE = 'labgate';
87
90
  const UI_VERSION_CHECK_TIMEOUT_MS = 8_000;
88
91
  const UI_VERSION_CHECK_MAX_BUFFER = 256 * 1024;
89
92
  const UI_VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -94,10 +97,34 @@ const SESSION_GIT_CACHE_TTL_MS = 4_000;
94
97
  const SESSION_GIT_COMMAND_TIMEOUT_MS = 1_500;
95
98
  const SESSION_GIT_COMMAND_MAX_BUFFER = 512 * 1024;
96
99
  const SESSION_GIT_MUTATION_TIMEOUT_MS = 5_000;
100
+ const TEMPORARILY_DISABLED_WEB_UI_FEATURES = Object.freeze({
101
+ terminalBookmarks: true,
102
+ });
97
103
  const BROWSE_DIR_GIT_STATUS_TIMEOUT_MS = 2_500;
98
104
  const FILE_PREVIEW_DEFAULT_MAX_BYTES = 256 * 1024;
99
105
  const FILE_PREVIEW_MAX_BYTES_LIMIT = 1 * 1024 * 1024;
100
106
  const FILE_PREVIEW_BINARY_SCAN_BYTES = 4096;
107
+ const CLAUDE_BROWSER_URL_FILES = Array.from(new Set([
108
+ (0, path_1.join)((0, config_js_1.getSandboxHome)(), '.labgate', 'browser-url'),
109
+ (0, path_1.join)(config_js_1.LABGATE_DIR, 'ai-home', '.labgate', 'browser-url'),
110
+ (0, path_1.join)(config_js_1.LABGATE_DIR, '.labgate', 'browser-url'),
111
+ ]));
112
+ const CLAUDE_BROWSER_URL_MAX_AGE_MS = 10 * 60 * 1000;
113
+ const CLAUDE_AUTH_FALLBACK_URL_RE = /https:\/\/[A-Za-z0-9.-]+\/[^\s"'<>]+/g;
114
+ const CLAUDE_AUTH_FLOW_MAX_OUTPUT_CHARS = 220_000;
115
+ function parsePositiveIntEnv(raw, fallback) {
116
+ const text = String(raw || '').trim();
117
+ if (!text)
118
+ return fallback;
119
+ const parsed = Number.parseInt(text, 10);
120
+ if (!Number.isFinite(parsed) || parsed <= 0)
121
+ return fallback;
122
+ return parsed;
123
+ }
124
+ const CLAUDE_AUTH_FLOW_URL_WAIT_TIMEOUT_MS = parsePositiveIntEnv(process.env.LABGATE_CLAUDE_AUTH_FLOW_URL_WAIT_TIMEOUT_MS, 18_000);
125
+ const CLAUDE_AUTH_FLOW_CONFIRM_TIMEOUT_MS = 30_000;
126
+ const CLAUDE_AUTH_FLOW_IDLE_TTL_MS = 10 * 60 * 1000;
127
+ const CLAUDE_LOGIN_SUCCESS_RE = /login successful|successfully logged in|you are now logged in|logged in as\s+[^\s]+@[^\s]+/i;
101
128
  const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
102
129
  '\n' +
103
130
  'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
@@ -118,6 +145,7 @@ let displayStore = null;
118
145
  const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
119
146
  const webTerminalBridges = new Map();
120
147
  const automationEngines = new Map();
148
+ const claudeAuthLoginFlows = new Map();
121
149
  const WEB_TERMINAL_INIT_RETENTION_MS = 60 * 60 * 1000;
122
150
  const WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS = 60 * 60 * 1000;
123
151
  const WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER = 64 * 1024 * 1024;
@@ -125,15 +153,18 @@ const WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS = 30 * 60 * 1000;
125
153
  const WEB_TERMINAL_AGENT_PREP_MAX_BUFFER = 32 * 1024 * 1024;
126
154
  const WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS = 90 * 1000;
127
155
  const WEB_TERMINAL_STARTUP_READY_POLL_MS = 120;
156
+ const WEB_TERMINAL_STARTUP_PROGRESS_UPDATE_MS = 5_000;
128
157
  const WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT = 64 * 1024;
129
158
  const WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS = 750;
159
+ const WEB_TERMINAL_STARTUP_CAPTURE_FAST_CHECK_MS = 700;
160
+ const WEB_TERMINAL_STARTUP_CAPTURE_SLOW_CHECK_MS = 2_500;
161
+ const WEB_TERMINAL_STARTUP_CAPTURE_SKIP_ON_LIVE_MS = 900;
162
+ const WEB_TERMINAL_STARTUP_CAPTURE_LINES = '-200';
130
163
  const WEB_TERMINAL_BUFFER_MAX_BYTES = 512_000;
131
164
  const WEB_TERMINAL_HISTORY_MAX_BYTES = 8 * 1024 * 1024;
132
165
  const WEB_TERMINAL_HISTORY_CHUNK_BYTES = 8 * 1024;
133
166
  const WEB_TERMINAL_HISTORY_PAGE_DEFAULT = 120;
134
167
  const WEB_TERMINAL_HISTORY_PAGE_MAX = 600;
135
- const WEB_TERMINAL_STARTUP_HEADER_RE = /(?:^|\n)\s*LabGate\s*(?:\n|$)/i;
136
- const WEB_TERMINAL_STARTUP_BLOCKED_RE = /(?:^|\n)\s*Blocked\s+\d+\s+patterns\b/i;
137
168
  const CLAUDE_HEADLESS_STDERR_LIMIT = 12_000;
138
169
  const webTerminalInitJobs = new Map();
139
170
  const webTerminalImagePullLocks = new Map();
@@ -230,29 +261,6 @@ function readPackageVersion() {
230
261
  return '0.0.0';
231
262
  }
232
263
  }
233
- function stripVersionPrefix(raw) {
234
- return String(raw || '').trim().replace(/^v/i, '');
235
- }
236
- function parseSemverTriplet(version) {
237
- const normalized = stripVersionPrefix(version).split('-', 1)[0];
238
- const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/);
239
- if (!match)
240
- return null;
241
- return [Number(match[1]), Number(match[2]), Number(match[3])];
242
- }
243
- function compareSemverStrings(a, b) {
244
- const left = parseSemverTriplet(a);
245
- const right = parseSemverTriplet(b);
246
- if (!left || !right)
247
- return null;
248
- for (let i = 0; i < 3; i += 1) {
249
- if (left[i] > right[i])
250
- return 1;
251
- if (left[i] < right[i])
252
- return -1;
253
- }
254
- return 0;
255
- }
256
264
  function getUiBuildId() {
257
265
  try {
258
266
  const st = (0, fs_1.statSync)(HTML_PATH);
@@ -262,77 +270,11 @@ function getUiBuildId() {
262
270
  return `${LABGATE_UI_VERSION}:unknown`;
263
271
  }
264
272
  }
265
- function normalizeNpmVersionValue(raw) {
266
- if (typeof raw === 'string') {
267
- const cleaned = stripVersionPrefix(raw).trim();
268
- return cleaned || null;
269
- }
270
- if (Array.isArray(raw)) {
271
- const versions = raw
272
- .map((entry) => normalizeNpmVersionValue(entry))
273
- .filter((entry) => !!entry);
274
- if (versions.length === 0)
275
- return null;
276
- const sorted = [...versions].sort((a, b) => {
277
- const cmp = compareSemverStrings(a, b);
278
- if (cmp !== null)
279
- return cmp;
280
- return a.localeCompare(b);
281
- });
282
- return sorted[sorted.length - 1] || null;
283
- }
284
- return null;
285
- }
286
- function parseNpmVersionOutput(rawOutput) {
287
- const text = String(rawOutput || '').trim();
288
- if (!text)
289
- return null;
290
- try {
291
- return normalizeNpmVersionValue(JSON.parse(text));
292
- }
293
- catch {
294
- const cleaned = stripVersionPrefix(text.replace(/^"+|"+$/g, '').trim());
295
- return cleaned || null;
296
- }
297
- }
298
- function summarizeCommandError(err) {
299
- const detail = commandErrorDetail(err);
300
- const firstLine = detail
301
- .split('\n')
302
- .map((line) => line.trim())
303
- .find((line) => line.length > 0);
304
- if (!firstLine)
305
- return 'Could not reach npm registry.';
306
- return firstLine.length > 180 ? `${firstLine.slice(0, 177)}...` : firstLine;
307
- }
308
273
  async function fetchPublishedUiVersion() {
309
- const checkedAt = new Date().toISOString();
310
- try {
311
- const result = await execFileAsync('npm', ['view', UI_VERSION_NPM_PACKAGE, 'version', '--json'], {
312
- timeout: UI_VERSION_CHECK_TIMEOUT_MS,
313
- maxBuffer: UI_VERSION_CHECK_MAX_BUFFER,
314
- });
315
- const latestVersion = parseNpmVersionOutput(String(result?.stdout || ''));
316
- if (!latestVersion) {
317
- return {
318
- latestVersion: null,
319
- checkedAt,
320
- error: 'npm returned an unreadable version.',
321
- };
322
- }
323
- return {
324
- latestVersion,
325
- checkedAt,
326
- error: null,
327
- };
328
- }
329
- catch (err) {
330
- return {
331
- latestVersion: null,
332
- checkedAt,
333
- error: summarizeCommandError(err),
334
- };
335
- }
274
+ return (0, update_check_js_1.fetchPublishedNpmVersion)(update_check_js_1.LABGATE_NPM_PACKAGE, {
275
+ timeoutMs: UI_VERSION_CHECK_TIMEOUT_MS,
276
+ maxBuffer: UI_VERSION_CHECK_MAX_BUFFER,
277
+ });
336
278
  }
337
279
  async function getPublishedUiVersionCached(force = false) {
338
280
  const now = Date.now();
@@ -436,13 +378,13 @@ function runUiSelfUpdate() {
436
378
  status: 'running',
437
379
  startedAt,
438
380
  finishedAt: null,
439
- message: `Installing ${UI_VERSION_NPM_PACKAGE}@latest...`,
381
+ message: `Installing ${update_check_js_1.LABGATE_NPM_PACKAGE}@latest...`,
440
382
  error: null,
441
383
  latestVersion: null,
442
384
  };
443
385
  uiSelfUpdatePromise = (async () => {
444
386
  try {
445
- await execFileAsync('npm', ['install', '-g', `${UI_VERSION_NPM_PACKAGE}@latest`], {
387
+ await execFileAsync('npm', ['install', '-g', `${update_check_js_1.LABGATE_NPM_PACKAGE}@latest`], {
446
388
  timeout: UI_SELF_UPDATE_TIMEOUT_MS,
447
389
  maxBuffer: UI_SELF_UPDATE_MAX_BUFFER,
448
390
  });
@@ -462,7 +404,7 @@ function runUiSelfUpdate() {
462
404
  startedAt,
463
405
  finishedAt: new Date().toISOString(),
464
406
  message: 'Update failed.',
465
- error: summarizeCommandError(err),
407
+ error: (0, update_check_js_1.summarizeCommandError)(err),
466
408
  latestVersion: null,
467
409
  };
468
410
  }
@@ -647,20 +589,47 @@ async function ensureWebTerminalImageReady(runtime, image, onProgress) {
647
589
  const imagesDir = (0, config_js_1.getImagesDir)();
648
590
  const sifPath = (0, path_1.join)(imagesDir, (0, container_js_1.imageToSifName)(image));
649
591
  const pullLockPath = `${sifPath}.pull.lock`;
650
- if ((0, fs_1.existsSync)(sifPath) && !(0, fs_1.existsSync)(pullLockPath))
592
+ if ((0, container_js_1.isUsableApptainerSif)('apptainer', sifPath) && !(0, fs_1.existsSync)(pullLockPath))
651
593
  return;
652
594
  (0, fs_1.mkdirSync)(imagesDir, { recursive: true });
653
595
  await withWebTerminalImagePullLock(`apptainer:${image}`, async () => {
654
- if ((0, fs_1.existsSync)(sifPath) && !(0, fs_1.existsSync)(pullLockPath))
596
+ if ((0, container_js_1.isUsableApptainerSif)('apptainer', sifPath) && !(0, fs_1.existsSync)(pullLockPath))
655
597
  return;
656
598
  await (0, image_pull_lock_js_1.withImagePullFileLock)(pullLockPath, image, async () => {
657
- if ((0, fs_1.existsSync)(sifPath))
599
+ if ((0, container_js_1.isUsableApptainerSif)('apptainer', sifPath))
658
600
  return;
601
+ if ((0, fs_1.existsSync)(sifPath)) {
602
+ const message = `Cached image for ${image} failed validation. Re-pulling...`;
603
+ onProgress?.('image_pull', message);
604
+ if (!onProgress)
605
+ log.warn(message);
606
+ try {
607
+ (0, fs_1.unlinkSync)(sifPath);
608
+ }
609
+ catch {
610
+ // Best effort; the pull below will surface remaining problems.
611
+ }
612
+ }
613
+ const tempSifPath = `${sifPath}.tmp-${process.pid}-${(0, crypto_1.randomBytes)(6).toString('hex')}`;
659
614
  onProgress?.('image_pull', `Pulling container image ${image}...`);
660
- await execFileAsync('apptainer', ['pull', sifPath, `docker://${image}`], {
661
- timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
662
- maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
663
- });
615
+ try {
616
+ await execFileAsync('apptainer', ['pull', tempSifPath, `docker://${image}`], {
617
+ timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
618
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
619
+ });
620
+ if (!(0, container_js_1.isUsableApptainerSif)('apptainer', tempSifPath)) {
621
+ throw new Error(`Pulled SIF failed validation: ${tempSifPath}`);
622
+ }
623
+ (0, fs_1.renameSync)(tempSifPath, sifPath);
624
+ }
625
+ finally {
626
+ try {
627
+ (0, fs_1.unlinkSync)(tempSifPath);
628
+ }
629
+ catch {
630
+ // Best effort cleanup for failed pulls.
631
+ }
632
+ }
664
633
  }, {
665
634
  onWait: () => {
666
635
  const message = `Waiting for another session to finish pulling ${image}...`;
@@ -825,15 +794,38 @@ function toRuntimeUnavailableResult(runtimeReady) {
825
794
  };
826
795
  }
827
796
  async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
797
+ const startupStartedAt = Date.now();
798
+ const startupTimings = [];
799
+ const recordStartupTiming = (label, startedAt) => {
800
+ startupTimings.push([label, Math.max(0, Date.now() - startedAt)]);
801
+ };
802
+ const formatStartupTiming = (ms) => (ms < 1000 ? `${Math.round(ms)}ms` : ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`);
803
+ const flushStartupTimingLog = (readiness, outcome) => {
804
+ const parts = startupTimings
805
+ .filter(([, ms]) => Number.isFinite(ms))
806
+ .map(([label, ms]) => `${label}=${formatStartupTiming(ms)}`);
807
+ if (readiness) {
808
+ parts.push(`readiness_source=${readiness.source}`);
809
+ parts.push(`readiness_wait=${formatStartupTiming(readiness.elapsedMs)}`);
810
+ parts.push(`capture_checks=${readiness.captureChecks}`);
811
+ }
812
+ parts.push(`total=${formatStartupTiming(Date.now() - startupStartedAt)}`);
813
+ log.step(`[labgate] web terminal startup (${agent}, ${outcome}): ${parts.join(', ')}`);
814
+ };
828
815
  const onProgress = opts.onProgress;
829
- const config = (0, config_js_1.loadConfig)();
816
+ const effective = (0, config_js_1.loadEffectiveConfig)();
817
+ const config = effective.config;
830
818
  onProgress?.('runtime_setup', 'Checking container runtime...');
819
+ const runtimeStartedAt = Date.now();
831
820
  const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
821
+ recordStartupTiming('runtime_setup', runtimeStartedAt);
832
822
  if (!runtimeReady.ok) {
823
+ flushStartupTimingLog(null, 'failed');
833
824
  return toRuntimeUnavailableResult(runtimeReady);
834
825
  }
835
826
  const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
836
827
  if (!runtimeCheck.ok || !runtimeCheck.runtime) {
828
+ flushStartupTimingLog(null, 'failed');
837
829
  return {
838
830
  ok: false,
839
831
  status: runtimeReady.initialized ? 502 : 503,
@@ -849,6 +841,7 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
849
841
  // Preflight tmux before any slow image/agent preparation to avoid unnecessary side effects.
850
842
  const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
851
843
  if (!tmuxAvailable.ok) {
844
+ flushStartupTimingLog(null, 'failed');
852
845
  return {
853
846
  ok: false,
854
847
  status: 500,
@@ -856,10 +849,14 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
856
849
  };
857
850
  }
858
851
  if (opts.prewarmImage) {
852
+ const imagePrepareStartedAt = Date.now();
859
853
  try {
860
854
  await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, onProgress);
855
+ recordStartupTiming('image_prepare', imagePrepareStartedAt);
861
856
  }
862
857
  catch (err) {
858
+ recordStartupTiming('image_prepare', imagePrepareStartedAt);
859
+ flushStartupTimingLog(null, 'failed');
863
860
  const detail = commandErrorDetail(err);
864
861
  return {
865
862
  ok: false,
@@ -876,10 +873,14 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
876
873
  }
877
874
  }
878
875
  if (opts.prewarmAgent) {
876
+ const agentPrepareStartedAt = Date.now();
879
877
  try {
880
878
  await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, agent, resolvedWorkdir, config.network.mode, onProgress);
879
+ recordStartupTiming('agent_prepare', agentPrepareStartedAt);
881
880
  }
882
881
  catch (err) {
882
+ recordStartupTiming('agent_prepare', agentPrepareStartedAt);
883
+ flushStartupTimingLog(null, 'failed');
883
884
  const detail = commandErrorDetail(err);
884
885
  return {
885
886
  ok: false,
@@ -907,15 +908,19 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
907
908
  });
908
909
  (0, web_terminal_js_1.writeWebTerminalRecord)(record);
909
910
  onProgress?.('session_start', `Starting ${agent} terminal session...`);
911
+ const tmuxSessionStartedAt = Date.now();
910
912
  try {
911
913
  await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint, {
912
914
  permissionMode: opts.permissionMode || 'default',
913
915
  });
914
916
  (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
917
+ recordStartupTiming('tmux_session_start', tmuxSessionStartedAt);
915
918
  }
916
919
  catch (err) {
920
+ recordStartupTiming('tmux_session_start', tmuxSessionStartedAt);
917
921
  const message = err?.message ?? String(err);
918
922
  (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
923
+ flushStartupTimingLog(null, 'failed');
919
924
  return {
920
925
  ok: false,
921
926
  status: 500,
@@ -934,6 +939,7 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
934
939
  exitCode: 1,
935
940
  error: 'node-pty bridge unavailable',
936
941
  });
942
+ flushStartupTimingLog(null, 'failed');
937
943
  return {
938
944
  ok: false,
939
945
  status: 500,
@@ -943,7 +949,27 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
943
949
  },
944
950
  };
945
951
  }
946
- await waitForWebTerminalStartupSummary(record, bridge, onProgress);
952
+ const readinessWaitStartedAt = Date.now();
953
+ const readiness = await waitForWebTerminalStartupSummary(record, bridge, onProgress);
954
+ recordStartupTiming('web_terminal_readiness_wait', readinessWaitStartedAt);
955
+ if (!readiness.ready && readiness.source !== 'timeout') {
956
+ const message = readiness.source === 'bridge-detached'
957
+ ? 'Terminal bridge detached before startup completed.'
958
+ : readiness.source === 'tmux-exited'
959
+ ? 'tmux session exited before startup completed.'
960
+ : 'Terminal startup did not complete.';
961
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
962
+ flushStartupTimingLog(readiness, 'failed');
963
+ return {
964
+ ok: false,
965
+ status: 500,
966
+ body: { ok: false, error: message },
967
+ };
968
+ }
969
+ flushStartupTimingLog(readiness, 'ready');
970
+ if (!readiness.ready) {
971
+ onProgress?.('session_start', 'Terminal started. LabGate startup summary timed out; continuing with live session output.');
972
+ }
947
973
  return {
948
974
  ok: true,
949
975
  session: serializeWebTerminalSession(record),
@@ -1243,6 +1269,7 @@ function serializeWebTerminalSession(record) {
1243
1269
  return {
1244
1270
  id: record.id,
1245
1271
  name: record.name || '',
1272
+ starred: record.starred === true,
1246
1273
  agent: record.agent,
1247
1274
  runtime: record.runtime || '',
1248
1275
  workdir: record.workdir,
@@ -1368,6 +1395,49 @@ function collectClaudeTextFromContent(content) {
1368
1395
  }
1369
1396
  return text;
1370
1397
  }
1398
+ function collectToolResultText(value) {
1399
+ if (typeof value === 'string')
1400
+ return value;
1401
+ if (Array.isArray(value)) {
1402
+ let text = '';
1403
+ for (const item of value) {
1404
+ text += collectToolResultText(item);
1405
+ }
1406
+ return text;
1407
+ }
1408
+ if (!value || typeof value !== 'object')
1409
+ return '';
1410
+ const record = value;
1411
+ if (record.type === 'text' && typeof record.text === 'string')
1412
+ return record.text;
1413
+ if (typeof record.content === 'string')
1414
+ return record.content;
1415
+ if (record.content !== undefined)
1416
+ return collectToolResultText(record.content);
1417
+ if (typeof record.text === 'string')
1418
+ return record.text;
1419
+ return '';
1420
+ }
1421
+ function summarizeToolResultDetail(raw, max = 420) {
1422
+ const compact = String(raw || '').replace(/\r/g, '').trim();
1423
+ if (!compact)
1424
+ return '';
1425
+ if (compact.length <= max)
1426
+ return compact;
1427
+ return `${compact.slice(0, Math.max(1, max - 3))}...`;
1428
+ }
1429
+ function extractToolResultDetailFromBlock(block) {
1430
+ const fromContent = summarizeToolResultDetail(collectToolResultText(block.content));
1431
+ if (fromContent)
1432
+ return fromContent;
1433
+ const fromError = summarizeToolResultDetail(readRecordString(block, 'error'));
1434
+ if (fromError)
1435
+ return fromError;
1436
+ const fromMessage = summarizeToolResultDetail(readRecordString(block, 'message'));
1437
+ if (fromMessage)
1438
+ return fromMessage;
1439
+ return '';
1440
+ }
1371
1441
  function extractClaudeStreamSessionId(event) {
1372
1442
  const direct = readRecordString(event, 'session_id').trim();
1373
1443
  if (direct)
@@ -1402,87 +1472,288 @@ function isClaudeAuthenticationFailure(event, assistantSnapshot, stderrText) {
1402
1472
  const authRe = /oauth token has expired|authentication_error|failed to authenticate|api error:\s*401/i;
1403
1473
  return authRe.test(packed) || authRe.test(message);
1404
1474
  }
1405
- function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSessionId, runWithAllowedPermissions) {
1406
- const sandboxHome = (0, config_js_1.getSandboxHome)();
1407
- const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
1408
- const resume = resumeSessionId.trim();
1409
- // Ensure display.json exists before bind-mounting it
1475
+ function isDisplayWidgetToolName(rawName) {
1476
+ const normalized = normalizeToolName(rawName)
1477
+ .replace(/[\s.-]+/g, '_');
1478
+ if (!normalized)
1479
+ return false;
1480
+ return normalized === 'display_widget'
1481
+ || normalized.endsWith('__display_widget')
1482
+ || normalized.endsWith('_display_widget');
1483
+ }
1484
+ function ensureDisplayDbFileReady() {
1410
1485
  const displayDbPath = (0, config_js_1.getDisplayDbPath)();
1411
- if (!(0, fs_1.existsSync)(displayDbPath)) {
1412
- (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(displayDbPath));
1413
- (0, fs_1.writeFileSync)(displayDbPath, JSON.stringify({ version: 1, events: [] }, null, 2) + '\n', {
1414
- encoding: 'utf-8',
1415
- mode: config_js_1.PRIVATE_FILE_MODE,
1416
- });
1486
+ if ((0, fs_1.existsSync)(displayDbPath))
1487
+ return;
1488
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(displayDbPath));
1489
+ (0, fs_1.writeFileSync)(displayDbPath, JSON.stringify({ version: 1, events: [] }, null, 2) + '\n', {
1490
+ encoding: 'utf-8',
1491
+ mode: config_js_1.PRIVATE_FILE_MODE,
1492
+ });
1493
+ }
1494
+ function buildClaudeHeadlessAddDirArgs(config) {
1495
+ const dirs = new Set();
1496
+ let hasExtraMounts = false;
1497
+ let hasDatasets = false;
1498
+ for (const mount of config.filesystem.extra_paths || []) {
1499
+ if (!mount || typeof mount.path !== 'string')
1500
+ continue;
1501
+ const resolved = mount.path.replace(/^~/, (0, os_1.homedir)());
1502
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1503
+ hasExtraMounts = true;
1504
+ dirs.add(target);
1417
1505
  }
1506
+ for (const ds of config.datasets || []) {
1507
+ if (!ds || typeof ds.name !== 'string')
1508
+ continue;
1509
+ const name = ds.name.trim();
1510
+ if (!name)
1511
+ continue;
1512
+ hasDatasets = true;
1513
+ dirs.add(`/datasets/${name}`);
1514
+ }
1515
+ if (hasExtraMounts)
1516
+ dirs.add('/mnt');
1517
+ if (hasDatasets)
1518
+ dirs.add('/datasets');
1519
+ return Array.from(dirs).flatMap((dir) => ['--add-dir', dir]);
1520
+ }
1521
+ function buildClaudeHeadlessAllowedToolsArgs() {
1522
+ // Keep approvals enabled globally, but pre-allow the tools needed for
1523
+ // headless discovery + web lookups in mounted workspaces.
1524
+ return ['--allowed-tools', 'Glob,Read,Grep,WebFetch'];
1525
+ }
1526
+ function buildClaudeHeadlessInvocationArgs(config, prompt, resumeSessionId, runWithAllowedPermissions) {
1527
+ const resume = resumeSessionId.trim();
1418
1528
  return [
1419
- 'exec',
1420
- '--containall',
1421
- '--cleanenv',
1422
- '--home', `${sandboxHome}:/home/sandbox`,
1423
- '--bind', `${workdir}:/work`,
1424
- '--pwd', '/work',
1425
- ...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
1426
- const resolved = p.replace(/^~/, (0, os_1.homedir)());
1427
- const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1428
- const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
1429
- return ['--bind', bindSpec];
1430
- }),
1431
- ...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
1432
- const resolved = p.replace(/^~/, (0, os_1.homedir)());
1433
- const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
1434
- return ['--bind', bindSpec];
1435
- }),
1436
- ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1437
- '--bind', `${(0, config_js_1.getDisplayDbPath)()}:/labgate-config/display.json`,
1438
- '--env', 'HOME=/home/sandbox',
1439
- '--env', 'ANTHROPIC_API_KEY=',
1440
- sifPath,
1441
1529
  '/home/sandbox/.npm-global/bin/claude',
1442
1530
  '-p',
1443
1531
  '--verbose',
1444
1532
  '--output-format',
1445
1533
  'stream-json',
1446
1534
  '--include-partial-messages',
1535
+ ...buildClaudeHeadlessAddDirArgs(config),
1536
+ ...buildClaudeHeadlessAllowedToolsArgs(),
1447
1537
  ...(runWithAllowedPermissions ? ['--dangerously-skip-permissions'] : []),
1448
1538
  ...(resume ? ['--resume', resume] : []),
1449
1539
  prompt,
1450
1540
  ];
1451
1541
  }
1542
+ function buildClaudeHeadlessRuntimeCommand(runtime, config, workdir, prompt, resumeSessionId, runWithAllowedPermissions) {
1543
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
1544
+ // Ensure display.json exists before bind-mounting it
1545
+ ensureDisplayDbFileReady();
1546
+ const displayDbPath = (0, config_js_1.getDisplayDbPath)();
1547
+ const claudeArgs = buildClaudeHeadlessInvocationArgs(config, prompt, resumeSessionId, runWithAllowedPermissions);
1548
+ if (runtime === 'podman') {
1549
+ return {
1550
+ command: 'podman',
1551
+ args: [
1552
+ 'run',
1553
+ '--rm',
1554
+ '--workdir', '/work',
1555
+ '--volume', `${sandboxHome}:/home/sandbox`,
1556
+ '--volume', `${workdir}:/work`,
1557
+ ...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
1558
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1559
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1560
+ const volSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
1561
+ return ['--volume', volSpec];
1562
+ }),
1563
+ ...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
1564
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1565
+ const volSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
1566
+ return ['--volume', volSpec];
1567
+ }),
1568
+ ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--volume', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1569
+ '--volume', `${displayDbPath}:/labgate-config/display.json`,
1570
+ ...getPodmanPrewarmNetworkArgs(config.network.mode),
1571
+ '--env', 'HOME=/home/sandbox',
1572
+ '--env', 'ANTHROPIC_API_KEY=',
1573
+ config.image,
1574
+ ...claudeArgs,
1575
+ ],
1576
+ };
1577
+ }
1578
+ const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
1579
+ return {
1580
+ command: 'apptainer',
1581
+ args: [
1582
+ 'exec',
1583
+ '--containall',
1584
+ '--cleanenv',
1585
+ '--home', `${sandboxHome}:/home/sandbox`,
1586
+ '--bind', `${workdir}:/work`,
1587
+ '--pwd', '/work',
1588
+ ...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
1589
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1590
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1591
+ const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
1592
+ return ['--bind', bindSpec];
1593
+ }),
1594
+ ...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
1595
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1596
+ const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
1597
+ return ['--bind', bindSpec];
1598
+ }),
1599
+ ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1600
+ '--bind', `${displayDbPath}:/labgate-config/display.json`,
1601
+ '--env', 'HOME=/home/sandbox',
1602
+ '--env', 'ANTHROPIC_API_KEY=',
1603
+ sifPath,
1604
+ ...claudeArgs,
1605
+ ],
1606
+ };
1607
+ }
1452
1608
  function sleep(ms) {
1453
1609
  return new Promise((resolve) => setTimeout(resolve, ms));
1454
1610
  }
1455
- function stripAnsiForStartupReadiness(text) {
1456
- return String(text || '')
1457
- .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
1458
- .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
1459
- .replace(/\r/g, '\n');
1460
- }
1461
- function hasWebTerminalStartupSummary(buffer) {
1462
- const plain = stripAnsiForStartupReadiness(buffer);
1463
- return WEB_TERMINAL_STARTUP_HEADER_RE.test(plain) && WEB_TERMINAL_STARTUP_BLOCKED_RE.test(plain);
1464
- }
1465
- async function waitForWebTerminalStartupSummary(record, bridge, onProgress) {
1466
- const deadline = Date.now() + WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS;
1611
+ async function waitForWebTerminalStartupSummary(record, bridge, onProgress, deps = {}) {
1612
+ const formatElapsedLabel = (elapsedMs) => {
1613
+ const totalSeconds = Math.max(1, Math.round(elapsedMs / 1000));
1614
+ if (totalSeconds < 60)
1615
+ return `${totalSeconds}s`;
1616
+ const minutes = Math.floor(totalSeconds / 60);
1617
+ const seconds = totalSeconds % 60;
1618
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
1619
+ };
1620
+ const describeWait = (sawLaunchSignal, sawSummary, elapsedMs) => {
1621
+ const agentLabel = record.agent === 'claude' ? 'Claude' : 'Codex';
1622
+ let message = `Waiting for ${agentLabel} startup output`;
1623
+ if (sawLaunchSignal && !sawSummary) {
1624
+ message = `${agentLabel} launched. Waiting for LabGate startup summary`;
1625
+ }
1626
+ else if (!sawLaunchSignal && sawSummary) {
1627
+ message = `LabGate startup summary detected. Waiting for ${agentLabel} launch banner`;
1628
+ }
1629
+ if (!Number.isFinite(elapsedMs) || !elapsedMs || elapsedMs <= 0)
1630
+ return `${message}...`;
1631
+ const slowHint = elapsedMs >= 30_000 ? '; initial auth/setup can be slow' : '';
1632
+ return `${message}... (${formatElapsedLabel(elapsedMs)} elapsed${slowHint})`;
1633
+ };
1634
+ const nowFn = deps.now ?? Date.now;
1635
+ const sleepFn = deps.sleep ?? sleep;
1636
+ const hasTmuxSessionFn = deps.hasTmuxSession ?? web_terminal_js_1.hasTmuxSession;
1637
+ const getTmuxBinaryFn = deps.getTmuxBinary ?? web_terminal_js_1.getTmuxBinary;
1638
+ const capturePane = deps.capturePane ?? (async (tmuxBin, sessionName) => {
1639
+ const { stdout } = await execFileAsync(tmuxBin, ['capture-pane', '-p', '-S', WEB_TERMINAL_STARTUP_CAPTURE_LINES, '-t', sessionName], { timeout: 2_000, maxBuffer: WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT });
1640
+ return String(stdout || '');
1641
+ });
1642
+ const startMs = nowFn();
1643
+ const deadline = startMs + WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS;
1467
1644
  let lastAliveCheck = 0;
1468
- onProgress?.('session_start', `Finalizing ${record.agent} startup...`);
1469
- while (Date.now() < deadline) {
1645
+ let lastCaptureCheck = 0;
1646
+ let lastProgressBucket = 0;
1647
+ let lastLiveOutputAt = startMs;
1648
+ let lastObservedBufferLength = 0;
1649
+ let tmuxBin = null;
1650
+ let sawLaunchSignal = false;
1651
+ let sawSummary = false;
1652
+ let sawDeviceAuth = false;
1653
+ let captureChecks = 0;
1654
+ const captureIntervalFor = (elapsedMs) => {
1655
+ if (sawLaunchSignal || sawSummary)
1656
+ return WEB_TERMINAL_STARTUP_CAPTURE_SLOW_CHECK_MS;
1657
+ return elapsedMs < 5_000 ? WEB_TERMINAL_STARTUP_CAPTURE_FAST_CHECK_MS : WEB_TERMINAL_STARTUP_CAPTURE_SLOW_CHECK_MS;
1658
+ };
1659
+ const observeStartupSignals = (text, source) => {
1660
+ const signals = (0, web_terminal_startup_readiness_js_1.getWebTerminalStartupSignals)(record.agent, text);
1661
+ const hadLaunchSignal = sawLaunchSignal;
1662
+ const hadSummary = sawSummary;
1663
+ sawLaunchSignal ||= signals.hasLaunchSignal;
1664
+ sawSummary ||= signals.hasSummary;
1665
+ sawDeviceAuth ||= signals.launchKind === 'device-auth';
1666
+ if (!sawLaunchSignal || !sawSummary)
1667
+ return null;
1668
+ if (sawDeviceAuth)
1669
+ return 'device-auth';
1670
+ if (signals.hasLaunchSignal && signals.hasSummary) {
1671
+ return source === 'live' ? 'live-bridge' : 'tmux-capture';
1672
+ }
1673
+ if (hadLaunchSignal !== sawLaunchSignal || hadSummary !== sawSummary) {
1674
+ return 'latched-launch+summary';
1675
+ }
1676
+ return source === 'live' ? 'live-bridge' : 'tmux-capture';
1677
+ };
1678
+ onProgress?.('session_start', describeWait(false, false));
1679
+ while (nowFn() < deadline) {
1680
+ if (bridge.buffer.length !== lastObservedBufferLength) {
1681
+ lastObservedBufferLength = bridge.buffer.length;
1682
+ lastLiveOutputAt = nowFn();
1683
+ }
1470
1684
  const recent = bridge.buffer.length > WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT
1471
1685
  ? bridge.buffer.slice(-WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT)
1472
1686
  : bridge.buffer;
1473
- if (hasWebTerminalStartupSummary(recent))
1474
- return;
1475
- if (!bridge.pty)
1476
- return;
1477
- const now = Date.now();
1687
+ const liveReadySource = (0, web_terminal_startup_readiness_js_1.isWebTerminalStartupReady)(record.agent, recent)
1688
+ ? ((0, web_terminal_startup_readiness_js_1.getWebTerminalStartupSignals)(record.agent, recent).launchKind === 'device-auth' ? 'device-auth' : 'live-bridge')
1689
+ : observeStartupSignals(recent, 'live');
1690
+ if (liveReadySource) {
1691
+ return {
1692
+ ready: true,
1693
+ source: liveReadySource,
1694
+ elapsedMs: Math.max(0, nowFn() - startMs),
1695
+ captureChecks,
1696
+ };
1697
+ }
1698
+ if (!bridge.pty) {
1699
+ return {
1700
+ ready: false,
1701
+ source: 'bridge-detached',
1702
+ elapsedMs: Math.max(0, nowFn() - startMs),
1703
+ captureChecks,
1704
+ };
1705
+ }
1706
+ const now = nowFn();
1478
1707
  if (now - lastAliveCheck >= WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS) {
1479
1708
  lastAliveCheck = now;
1480
- const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
1481
- if (!alive)
1482
- return;
1709
+ const alive = await hasTmuxSessionFn(record.tmuxSession);
1710
+ if (!alive) {
1711
+ return {
1712
+ ready: false,
1713
+ source: 'tmux-exited',
1714
+ elapsedMs: Math.max(0, nowFn() - startMs),
1715
+ captureChecks,
1716
+ };
1717
+ }
1718
+ }
1719
+ const captureIntervalMs = captureIntervalFor(now - startMs);
1720
+ const recentLiveOutput = (now - lastLiveOutputAt) < WEB_TERMINAL_STARTUP_CAPTURE_SKIP_ON_LIVE_MS;
1721
+ if ((now - lastCaptureCheck) >= captureIntervalMs && !(recentLiveOutput && (sawLaunchSignal || sawSummary))) {
1722
+ lastCaptureCheck = now;
1723
+ try {
1724
+ tmuxBin ||= await getTmuxBinaryFn();
1725
+ captureChecks += 1;
1726
+ const captured = await capturePane(tmuxBin, record.tmuxSession);
1727
+ const captureReadySource = (0, web_terminal_startup_readiness_js_1.isWebTerminalStartupReady)(record.agent, captured)
1728
+ ? ((0, web_terminal_startup_readiness_js_1.getWebTerminalStartupSignals)(record.agent, captured).launchKind === 'device-auth' ? 'device-auth' : 'tmux-capture')
1729
+ : observeStartupSignals(captured, 'capture');
1730
+ if (captureReadySource) {
1731
+ return {
1732
+ ready: true,
1733
+ source: captureReadySource,
1734
+ elapsedMs: Math.max(0, nowFn() - startMs),
1735
+ captureChecks,
1736
+ };
1737
+ }
1738
+ }
1739
+ catch {
1740
+ // Best effort only. Live bridge output remains the primary signal.
1741
+ }
1483
1742
  }
1484
- await sleep(WEB_TERMINAL_STARTUP_READY_POLL_MS);
1743
+ const elapsedMs = nowFn() - startMs;
1744
+ const progressBucket = Math.floor(elapsedMs / WEB_TERMINAL_STARTUP_PROGRESS_UPDATE_MS);
1745
+ if (progressBucket > lastProgressBucket) {
1746
+ lastProgressBucket = progressBucket;
1747
+ onProgress?.('session_start', describeWait(sawLaunchSignal, sawSummary, elapsedMs));
1748
+ }
1749
+ await sleepFn(WEB_TERMINAL_STARTUP_READY_POLL_MS);
1485
1750
  }
1751
+ return {
1752
+ ready: false,
1753
+ source: 'timeout',
1754
+ elapsedMs: Math.max(0, nowFn() - startMs),
1755
+ captureChecks,
1756
+ };
1486
1757
  }
1487
1758
  function broadcastWebTerminalMessage(bridge, payload) {
1488
1759
  for (const ws of bridge.clients) {
@@ -1571,12 +1842,12 @@ async function ensureWebTerminalBridge(record) {
1571
1842
  env,
1572
1843
  };
1573
1844
  try {
1574
- ptyProcess = ptyModule.spawn(tmuxBin, ['attach-session', '-t', record.tmuxSession], spawnOpts);
1845
+ ptyProcess = ptyModule.spawn(tmuxBin, ['attach-session', '-d', '-t', record.tmuxSession], spawnOpts);
1575
1846
  }
1576
1847
  catch (err) {
1577
1848
  const shell = (process.env.SHELL || '/bin/bash').trim() || '/bin/bash';
1578
1849
  const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
1579
- const launch = `${quote(tmuxBin)} attach-session -t ${quote(record.tmuxSession)}`;
1850
+ const launch = `${quote(tmuxBin)} attach-session -d -t ${quote(record.tmuxSession)}`;
1580
1851
  try {
1581
1852
  ptyProcess = ptyModule.spawn(shell, ['-lc', launch], spawnOpts);
1582
1853
  }
@@ -1599,6 +1870,18 @@ async function ensureWebTerminalBridge(record) {
1599
1870
  bridge.stopRequested = false;
1600
1871
  bridge.pty = ptyProcess;
1601
1872
  webTerminalBridges.set(record.id, bridge);
1873
+ if (!existing && bridge.history.length === 0) {
1874
+ try {
1875
+ const { stdout } = await execFileAsync(tmuxBin, ['capture-pane', '-p', '-S', WEB_TERMINAL_STARTUP_CAPTURE_LINES, '-t', record.tmuxSession], { timeout: 2_000, maxBuffer: WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT });
1876
+ const snapshot = String(stdout || '');
1877
+ if (snapshot) {
1878
+ appendWebTerminalBuffer(bridge, snapshot);
1879
+ }
1880
+ }
1881
+ catch {
1882
+ // Best effort only; live bridge output continues to stream after attach.
1883
+ }
1884
+ }
1602
1885
  ptyProcess.onData((data) => {
1603
1886
  const appended = appendWebTerminalBuffer(bridge, data);
1604
1887
  for (const chunk of appended) {
@@ -1682,7 +1965,8 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1682
1965
  }
1683
1966
  send({ type: 'status', stage: 'runtime_setup', message: 'Checking container runtime...' });
1684
1967
  const config = (0, config_js_1.loadConfig)();
1685
- const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
1968
+ const runtimePreference = record.runtime || config.runtime;
1969
+ const runtimeReady = await prepareRuntimeForWebTerminal(runtimePreference);
1686
1970
  if (!runtimeReady.ok) {
1687
1971
  send({
1688
1972
  type: 'error',
@@ -1691,7 +1975,7 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1691
1975
  });
1692
1976
  return () => { };
1693
1977
  }
1694
- const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
1978
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(runtimePreference);
1695
1979
  if (!runtimeCheck.ok || !runtimeCheck.runtime) {
1696
1980
  send({
1697
1981
  type: 'error',
@@ -1700,16 +1984,17 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1700
1984
  });
1701
1985
  return () => { };
1702
1986
  }
1703
- if (runtimeCheck.runtime !== 'apptainer') {
1987
+ const runtime = runtimeCheck.runtime;
1988
+ if (runtime !== 'apptainer' && runtime !== 'podman') {
1704
1989
  send({
1705
1990
  type: 'error',
1706
1991
  code: 'runtime_unsupported',
1707
- error: `Headless Claude chat currently supports Apptainer only (detected: ${runtimeCheck.runtime}).`,
1992
+ error: `Headless Claude chat requires an Apptainer or Podman runtime (detected: ${runtime}).`,
1708
1993
  });
1709
1994
  return () => { };
1710
1995
  }
1711
1996
  try {
1712
- await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, (stage, message) => {
1997
+ await ensureWebTerminalImageReady(runtime, config.image, (stage, message) => {
1713
1998
  send({ type: 'status', stage, message });
1714
1999
  });
1715
2000
  }
@@ -1722,7 +2007,7 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1722
2007
  return () => { };
1723
2008
  }
1724
2009
  try {
1725
- await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, 'claude', record.workdir, config.network.mode, (stage, message) => send({ type: 'status', stage, message }));
2010
+ await ensureWebTerminalAgentReady(runtime, config.image, 'claude', record.workdir, config.network.mode, (stage, message) => send({ type: 'status', stage, message }));
1726
2011
  }
1727
2012
  catch (err) {
1728
2013
  send({
@@ -1732,8 +2017,8 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1732
2017
  });
1733
2018
  return () => { };
1734
2019
  }
1735
- const args = buildClaudeHeadlessApptainerArgs(config, record.workdir, trimmedPrompt, resumeSessionId, (0, config_js_1.shouldClaudeHeadlessRunWithAllowedPermissions)(config));
1736
- const child = (0, child_process_1.spawn)('apptainer', args, {
2020
+ const command = buildClaudeHeadlessRuntimeCommand(runtime, config, record.workdir, trimmedPrompt, resumeSessionId, (0, config_js_1.shouldClaudeHeadlessRunWithAllowedPermissions)(config));
2021
+ const child = (0, child_process_1.spawn)(command.command, command.args, {
1737
2022
  cwd: record.workdir,
1738
2023
  env: process.env,
1739
2024
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1744,6 +2029,7 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1744
2029
  let emittedAssistantText = '';
1745
2030
  let doneSent = false;
1746
2031
  let syntheticToolUseSeq = 0;
2032
+ let authRequiredSent = false;
1747
2033
  const sendDone = (exitCode) => {
1748
2034
  if (doneSent)
1749
2035
  return;
@@ -1806,12 +2092,12 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1806
2092
  const detail = extractToolDetailFromToolUseBlock(toolBlock);
1807
2093
  const toolUseId = normalizeToolUseId(toolBlock.id) || `tool-${Date.now().toString(36)}-${(++syntheticToolUseSeq).toString(36)}`;
1808
2094
  // Intercept display_widget calls and forward rich content payload
1809
- if (toolName === 'display_widget') {
1810
- const input = toolBlock.input;
1811
- if (input && typeof input.widget === 'string') {
2095
+ if (isDisplayWidgetToolName(toolName)) {
2096
+ const input = parseToolInput(toolBlock.input ?? toolBlock.arguments ?? toolBlock.params);
2097
+ if (typeof input.widget === 'string' && input.widget.trim()) {
1812
2098
  send({
1813
2099
  type: 'rich_content',
1814
- widget: String(input.widget),
2100
+ widget: String(input.widget).trim(),
1815
2101
  title: input.title ? String(input.title) : undefined,
1816
2102
  data: (input.data && typeof input.data === 'object') ? input.data : {},
1817
2103
  id: toolUseId,
@@ -1832,19 +2118,22 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1832
2118
  if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_result') {
1833
2119
  const resultBlock = block;
1834
2120
  const toolUseId = normalizeToolUseId(resultBlock.tool_use_id);
2121
+ const detail = extractToolResultDetailFromBlock(resultBlock);
1835
2122
  send({
1836
2123
  type: 'tool_result',
1837
2124
  tool_use_id: toolUseId || undefined,
1838
2125
  is_error: !!resultBlock.is_error,
2126
+ detail: detail || undefined,
1839
2127
  });
1840
2128
  }
1841
2129
  }
1842
2130
  }
1843
2131
  }
1844
- if (isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
2132
+ if (!authRequiredSent && isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
2133
+ authRequiredSent = true;
1845
2134
  send({
1846
2135
  type: 'auth_required',
1847
- error: 'Claude authentication is required. Run /login in raw terminal mode to refresh session.',
2136
+ error: 'Claude authentication is required. Type /login in chat (or run `claude auth login` in raw mode) to refresh session.',
1848
2137
  });
1849
2138
  }
1850
2139
  }
@@ -2000,21 +2289,7 @@ async function handlePostConfig(req, res) {
2000
2289
  }
2001
2290
  }
2002
2291
  const configPath = (0, config_js_1.getConfigPath)();
2003
- // Read existing raw file and parse (strip comments)
2004
- let obj = {};
2005
- if ((0, fs_1.existsSync)(configPath)) {
2006
- const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
2007
- const stripped = rawText
2008
- .split('\n')
2009
- .filter(line => !line.trimStart().startsWith('//'))
2010
- .join('\n');
2011
- try {
2012
- obj = JSON.parse(stripped);
2013
- }
2014
- catch {
2015
- obj = {};
2016
- }
2017
- }
2292
+ const obj = (0, config_js_1.readRawConfigFile)(configPath);
2018
2293
  // Merge incoming config
2019
2294
  obj.runtime = incoming.runtime;
2020
2295
  obj.image = incoming.image;
@@ -2030,9 +2305,7 @@ async function handlePostConfig(req, res) {
2030
2305
  obj.headless = incoming.headless;
2031
2306
  if (incoming.plugins)
2032
2307
  obj.plugins = incoming.plugins;
2033
- const { writeFileSync } = await import('fs');
2034
- writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
2035
- (0, config_js_1.ensurePrivateFile)(configPath);
2308
+ (0, config_js_1.writeRawConfigFile)(obj, configPath);
2036
2309
  json(res, { ok: true });
2037
2310
  }
2038
2311
  catch (err) {
@@ -2067,20 +2340,13 @@ async function handlePostPlugins(req, res) {
2067
2340
  }
2068
2341
  // Read existing config file
2069
2342
  const configPath = (0, config_js_1.getConfigPath)();
2070
- let obj = {};
2071
- if ((0, fs_1.existsSync)(configPath)) {
2072
- const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
2073
- const stripped = rawText
2074
- .split('\n')
2075
- .filter(line => !line.trimStart().startsWith('//'))
2076
- .join('\n');
2077
- try {
2078
- obj = JSON.parse(stripped);
2079
- }
2080
- catch (err) {
2081
- json(res, { ok: false, errors: [`Could not parse existing config file: ${err.message ?? String(err)}`] }, 400);
2082
- return;
2083
- }
2343
+ let obj;
2344
+ try {
2345
+ obj = (0, config_js_1.readRawConfigFile)(configPath);
2346
+ }
2347
+ catch (err) {
2348
+ json(res, { ok: false, errors: [`Could not parse existing config file: ${err.message ?? String(err)}`] }, 400);
2349
+ return;
2084
2350
  }
2085
2351
  // Merge plugin state
2086
2352
  const rawPlugins = (obj.plugins ?? {});
@@ -2096,9 +2362,7 @@ async function handlePostPlugins(req, res) {
2096
2362
  }
2097
2363
  plugins[pluginId] = enabled;
2098
2364
  obj.plugins = plugins;
2099
- const { writeFileSync } = await import('fs');
2100
- writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
2101
- (0, config_js_1.ensurePrivateFile)(configPath);
2365
+ (0, config_js_1.writeRawConfigFile)(obj, configPath);
2102
2366
  json(res, { ok: true, plugins });
2103
2367
  }
2104
2368
  catch (err) {
@@ -2307,13 +2571,7 @@ async function handleGetUiVersion(reqUrl, res) {
2307
2571
  const published = await getPublishedUiVersionCached(forceRefresh);
2308
2572
  const runningVersion = LABGATE_UI_VERSION;
2309
2573
  const latestVersion = published.latestVersion;
2310
- let updateAvailable = false;
2311
- if (latestVersion) {
2312
- const cmp = compareSemverStrings(latestVersion, runningVersion);
2313
- updateAvailable = cmp === null
2314
- ? stripVersionPrefix(latestVersion) !== stripVersionPrefix(runningVersion)
2315
- : cmp > 0;
2316
- }
2574
+ const updateAvailable = (0, update_check_js_1.isVersionNewer)(latestVersion, runningVersion);
2317
2575
  json(res, {
2318
2576
  ok: true,
2319
2577
  runningVersion,
@@ -2321,7 +2579,7 @@ async function handleGetUiVersion(reqUrl, res) {
2321
2579
  latestVersion,
2322
2580
  latestCheckedAt: published.checkedAt,
2323
2581
  updateAvailable,
2324
- updateCommand: `npm install -g ${UI_VERSION_NPM_PACKAGE}@latest`,
2582
+ updateCommand: (0, update_check_js_1.getUpdateCommand)(update_check_js_1.LABGATE_NPM_PACKAGE),
2325
2583
  restartCommand: 'labgate ui',
2326
2584
  checkError: published.error,
2327
2585
  });
@@ -2832,6 +3090,182 @@ function collectWebsiteUrls(value, accessedUrls, ts, keyHint = '') {
2832
3090
  }
2833
3091
  }
2834
3092
  }
3093
+ function normalizeToolName(rawName) {
3094
+ return String(rawName || '').trim().toLowerCase();
3095
+ }
3096
+ function parseToolInput(rawInput) {
3097
+ if (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) {
3098
+ return rawInput;
3099
+ }
3100
+ if (typeof rawInput === 'string') {
3101
+ try {
3102
+ const parsed = JSON.parse(rawInput);
3103
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
3104
+ return parsed;
3105
+ }
3106
+ }
3107
+ catch {
3108
+ // Some tools pass non-JSON strings; ignore.
3109
+ }
3110
+ }
3111
+ return {};
3112
+ }
3113
+ function classifyToolAction(toolName) {
3114
+ if (toolName === 'edit' ||
3115
+ toolName === 'multiedit' ||
3116
+ toolName === 'apply_patch' ||
3117
+ toolName.endsWith('.apply_patch') ||
3118
+ toolName === 'mcp__obsidian__patch_note')
3119
+ return 'edit';
3120
+ if (toolName === 'write' ||
3121
+ toolName === 'notebookedit' ||
3122
+ toolName === 'mcp__obsidian__write_note' ||
3123
+ toolName === 'mcp__obsidian__update_frontmatter' ||
3124
+ toolName === 'mcp__obsidian__move_note' ||
3125
+ toolName === 'mcp__obsidian__delete_note' ||
3126
+ toolName === 'mcp__obsidian__manage_tags')
3127
+ return 'write';
3128
+ if (toolName === 'read' ||
3129
+ toolName === 'glob' ||
3130
+ toolName === 'grep' ||
3131
+ toolName === 'mcp__obsidian__read_note' ||
3132
+ toolName === 'mcp__obsidian__read_multiple_notes' ||
3133
+ toolName === 'mcp__obsidian__get_frontmatter' ||
3134
+ toolName === 'mcp__obsidian__get_notes_info' ||
3135
+ toolName === 'mcp__obsidian__get_vault_stats' ||
3136
+ toolName === 'mcp__obsidian__list_directory' ||
3137
+ toolName === 'mcp__obsidian__search_notes')
3138
+ return 'read';
3139
+ return 'unknown';
3140
+ }
3141
+ function normalizeTrackedPath(rawPath) {
3142
+ const token = sanitizeTokenEdge(rawPath || '');
3143
+ if (!token)
3144
+ return '';
3145
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token))
3146
+ return '';
3147
+ if (token.startsWith('/'))
3148
+ return token;
3149
+ if (/^[A-Za-z]:[\\/]/.test(token))
3150
+ return token;
3151
+ if (token === '.' || token === '..')
3152
+ return '';
3153
+ const relative = token.replace(/^\.\/+/, '');
3154
+ return relative ? `/work/${relative}` : '';
3155
+ }
3156
+ function rememberToolPathValue(accessedFiles, value, ts, action) {
3157
+ if (typeof value === 'string') {
3158
+ const normalized = normalizeTrackedPath(value);
3159
+ if (normalized)
3160
+ rememberFileEntry(accessedFiles, normalized, ts, action);
3161
+ return;
3162
+ }
3163
+ if (Array.isArray(value)) {
3164
+ for (const item of value) {
3165
+ if (typeof item === 'string') {
3166
+ const normalized = normalizeTrackedPath(item);
3167
+ if (normalized)
3168
+ rememberFileEntry(accessedFiles, normalized, ts, action);
3169
+ }
3170
+ }
3171
+ }
3172
+ }
3173
+ function classifyCommandVerbAction(command) {
3174
+ const verb = String(command || '').trim().toLowerCase();
3175
+ if (!verb)
3176
+ return 'unknown';
3177
+ if ([
3178
+ 'cat', 'head', 'tail', 'less', 'more', 'wc', 'diff', 'grep', 'rg', 'awk',
3179
+ 'find', 'ls', 'stat', 'realpath', 'readlink',
3180
+ ].includes(verb))
3181
+ return 'read';
3182
+ if (['vi', 'vim', 'nano', 'code', 'sed', 'perl'].includes(verb))
3183
+ return 'edit';
3184
+ if (['write', 'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'mkdir', 'rmdir', 'truncate', 'tee'].includes(verb))
3185
+ return 'write';
3186
+ return 'unknown';
3187
+ }
3188
+ function collectFileAccessFromCommand(commandRaw, ts, accessedFiles, accessedUrls) {
3189
+ const cmd = typeof commandRaw === 'string' ? commandRaw : '';
3190
+ if (!cmd.trim())
3191
+ return;
3192
+ for (const url of extractWebsiteUrlsFromText(cmd)) {
3193
+ rememberTimestampedEntry(accessedUrls, url, ts);
3194
+ }
3195
+ const segments = cmd.split(/(?:\|\||&&|[|;])/).map((part) => part.trim()).filter(Boolean);
3196
+ for (const segment of segments) {
3197
+ const words = segment.split(/\s+/).filter(Boolean);
3198
+ if (words.length === 0)
3199
+ continue;
3200
+ const verb = words[0].replace(/^[^A-Za-z0-9._-]+/, '').replace(/[^\w.-]+$/, '');
3201
+ const action = classifyCommandVerbAction(verb);
3202
+ const absMatches = segment.match(/\/work\/\S+/g);
3203
+ if (absMatches) {
3204
+ for (const p of absMatches) {
3205
+ const clean = p.replace(/['"`;,)}\]]+$/, '');
3206
+ rememberFileEntry(accessedFiles, clean, ts, action);
3207
+ }
3208
+ }
3209
+ for (let idx = 1; idx < words.length; idx += 1) {
3210
+ const token = sanitizeTokenEdge(words[idx]);
3211
+ if (!token || token.startsWith('-') || token === '.' || token === '..')
3212
+ continue;
3213
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token))
3214
+ continue;
3215
+ if (token.startsWith('/')) {
3216
+ if (token.startsWith('/work/'))
3217
+ rememberFileEntry(accessedFiles, token, ts, action);
3218
+ continue;
3219
+ }
3220
+ if (token.includes('=') && !token.includes('/') && !token.includes('.'))
3221
+ continue;
3222
+ if (token.includes('/') || token.includes('.')) {
3223
+ const relative = token.replace(/^\.\/+/, '');
3224
+ if (relative)
3225
+ rememberFileEntry(accessedFiles, `/work/${relative}`, ts, action);
3226
+ }
3227
+ }
3228
+ }
3229
+ }
3230
+ function collectFileAccessFromPatch(patchRaw, ts, accessedFiles) {
3231
+ const patch = typeof patchRaw === 'string' ? patchRaw : '';
3232
+ if (!patch)
3233
+ return;
3234
+ const fileLineRe = /\*\*\* (?:Add|Update|Delete) File:\s+([^\n\r]+)/g;
3235
+ let match;
3236
+ while ((match = fileLineRe.exec(patch)) !== null) {
3237
+ const normalized = normalizeTrackedPath(match[1] || '');
3238
+ if (normalized)
3239
+ rememberFileEntry(accessedFiles, normalized, ts, 'edit');
3240
+ }
3241
+ }
3242
+ function collectFileAccessFromToolCall(rawName, rawInput, ts, accessedFiles, accessedUrls) {
3243
+ const toolName = normalizeToolName(rawName);
3244
+ const input = parseToolInput(rawInput);
3245
+ const action = classifyToolAction(toolName);
3246
+ collectWebsiteUrls(input, accessedUrls, ts);
3247
+ rememberToolPathValue(accessedFiles, input.file_path, ts, action);
3248
+ rememberToolPathValue(accessedFiles, input.path, ts, action);
3249
+ rememberToolPathValue(accessedFiles, input.paths, ts, action);
3250
+ rememberToolPathValue(accessedFiles, input.oldPath, ts, action);
3251
+ rememberToolPathValue(accessedFiles, input.newPath, ts, action);
3252
+ rememberToolPathValue(accessedFiles, input.confirmPath, ts, action);
3253
+ if (toolName === 'grep') {
3254
+ rememberToolPathValue(accessedFiles, input.path, ts, 'read');
3255
+ }
3256
+ if (toolName === 'bash' || toolName === 'exec_command' || toolName.endsWith('.exec_command')) {
3257
+ const command = typeof input.command === 'string'
3258
+ ? input.command
3259
+ : (typeof input.cmd === 'string' ? input.cmd : '');
3260
+ collectFileAccessFromCommand(command, ts, accessedFiles, accessedUrls);
3261
+ }
3262
+ if (toolName === 'apply_patch' || toolName.endsWith('.apply_patch')) {
3263
+ const patchText = typeof input.input === 'string'
3264
+ ? input.input
3265
+ : (typeof input.patch === 'string' ? input.patch : '');
3266
+ collectFileAccessFromPatch(patchText, ts, accessedFiles);
3267
+ }
3268
+ }
2835
3269
  /**
2836
3270
  * Tail-read the last ~16KB of a file and return the last parseable
2837
3271
  * non-snapshot JSONL entry, last user prompt, and recently accessed files.
@@ -2854,106 +3288,171 @@ function tailLastJsonlEntry(filePath) {
2854
3288
  const text = buf.toString('utf-8');
2855
3289
  const lines = text.split('\n').filter(l => l.trim().length > 0);
2856
3290
  let lastEntry = null;
3291
+ let lastEntryTsMs = 0;
2857
3292
  let lastUserPrompt = '';
2858
3293
  const accessedFiles = new Map();
2859
3294
  const accessedUrls = new Map();
3295
+ let timestampOffsetMs = null;
2860
3296
  // Walk backwards through all parseable lines
2861
3297
  for (let i = lines.length - 1; i >= 0; i--) {
2862
3298
  try {
2863
3299
  const obj = JSON.parse(lines[i]);
2864
- if (obj.type === 'summary' || obj.type === 'snapshot' || obj.type === 'file-history-snapshot')
3300
+ const entryType = String(obj.type || '');
3301
+ if (entryType === 'summary' || entryType === 'snapshot' || entryType === 'file-history-snapshot')
2865
3302
  continue;
2866
- if (!lastEntry)
2867
- lastEntry = obj;
2868
- const parsed = obj.timestamp ? new Date(obj.timestamp).getTime() : 0;
2869
- const ts = (parsed && !isNaN(parsed)) ? parsed : Date.now();
3303
+ const payload = (obj.payload && typeof obj.payload === 'object' && !Array.isArray(obj.payload))
3304
+ ? obj.payload
3305
+ : null;
3306
+ const parsed = typeof obj.timestamp === 'string' ? new Date(obj.timestamp).getTime() : 0;
3307
+ const hasParsedTimestamp = parsed && !isNaN(parsed);
3308
+ if (timestampOffsetMs === null && hasParsedTimestamp) {
3309
+ timestampOffsetMs = computeTranscriptTimestampOffsetMs(parsed, st.mtimeMs);
3310
+ }
3311
+ const ts = hasParsedTimestamp
3312
+ ? applyTranscriptTimestampOffset(parsed, timestampOffsetMs || 0)
3313
+ : Date.now();
2870
3314
  // Collect file paths from tool_use entries
2871
- if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
2872
- for (const block of obj.message.content) {
2873
- if (block.type !== 'tool_use' || !block.input)
2874
- continue;
2875
- const name = block.name || '';
2876
- collectWebsiteUrls(block.input, accessedUrls, ts);
2877
- // Classify tool action
2878
- const toolAction = (name === 'Edit' || name === 'edit') ? 'edit' :
2879
- (name === 'Write' || name === 'write' || name === 'NotebookEdit') ? 'write' :
2880
- (name === 'Read' || name === 'read' || name === 'Glob' || name === 'glob') ? 'read' :
2881
- (name === 'Grep' || name === 'grep') ? 'read' :
2882
- 'unknown';
2883
- // Direct file_path from Read/Edit/Write/Glob tools
2884
- const fp = block.input.file_path || block.input.path || '';
2885
- if (fp) {
2886
- rememberFileEntry(accessedFiles, fp, ts, toolAction);
2887
- }
2888
- // Grep pattern → search path
2889
- if ((name === 'Grep' || name === 'grep') && block.input.path) {
2890
- rememberFileEntry(accessedFiles, block.input.path, ts, 'read');
2891
- }
2892
- // Extract file paths from Bash commands
2893
- if (name === 'Bash' || name === 'bash') {
2894
- const cmd = block.input.command || '';
2895
- // Absolute /work/ paths
2896
- const absMatches = cmd.match(/\/work\/\S+/g);
2897
- if (absMatches) {
2898
- for (const p of absMatches) {
2899
- const clean = p.replace(/['"`;,)}\]]+$/, '');
2900
- rememberFileEntry(accessedFiles, clean, ts, 'unknown');
2901
- }
2902
- }
2903
- for (const url of extractWebsiteUrlsFromText(cmd)) {
2904
- rememberTimestampedEntry(accessedUrls, url, ts);
2905
- }
2906
- // Relative paths from file-accessing commands (cat, head, tail, less, vi, etc.)
2907
- // Also handles: python script.py, node file.js, chmod, cp, mv, rm, etc.
2908
- const argMatches = cmd.match(/(?:cat|head|tail|less|more|vi|vim|nano|code|python3?|node|chmod|cp|mv|rm|touch|wc|diff|grep|rg|sed|awk|source|bash|sh)\s+(?:-\S+\s+)*([^\s|>&;]+)/g);
2909
- if (argMatches) {
2910
- for (const m of argMatches) {
2911
- // Extract the file argument (last non-flag token)
2912
- const tokens = m.split(/\s+/).filter((t) => !t.startsWith('-'));
2913
- const fileArg = tokens[tokens.length - 1] || '';
2914
- if (fileArg && !fileArg.startsWith('/') && !fileArg.startsWith('-') && (fileArg.includes('/') || fileArg.includes('.'))) {
2915
- const full = '/work/' + fileArg;
2916
- rememberFileEntry(accessedFiles, full, ts, 'unknown');
2917
- }
2918
- }
2919
- }
3315
+ if (entryType === 'assistant') {
3316
+ const message = (obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message))
3317
+ ? obj.message
3318
+ : null;
3319
+ const content = message?.content;
3320
+ if (Array.isArray(content)) {
3321
+ for (const block of content) {
3322
+ if (!block || typeof block !== 'object' || Array.isArray(block))
3323
+ continue;
3324
+ const typedBlock = block;
3325
+ if (typedBlock.type !== 'tool_use')
3326
+ continue;
3327
+ collectFileAccessFromToolCall(typedBlock.name, typedBlock.input, ts, accessedFiles, accessedUrls);
2920
3328
  }
2921
3329
  }
2922
3330
  }
2923
- // Also extract paths from tool_result content (file listings, etc.)
2924
- if (obj.type === 'user' && Array.isArray(obj.message?.content)) {
2925
- for (const block of obj.message.content) {
2926
- if (block.type !== 'tool_result')
2927
- continue;
2928
- const text = extractTextContent(block.content);
2929
- // Look for /work/ paths in tool output
2930
- const pathMatches = text.match(/\/work\/[^\s:]+/g);
3331
+ // Codex transcript format: response_item.function_call entries
3332
+ if (entryType === 'response_item' && payload) {
3333
+ const payloadType = String(payload.type || '').toLowerCase();
3334
+ if (payloadType === 'function_call') {
3335
+ collectFileAccessFromToolCall(payload.name, payload.arguments, ts, accessedFiles, accessedUrls);
3336
+ if (!lastEntry) {
3337
+ lastEntry = {
3338
+ type: 'assistant',
3339
+ timestamp: obj.timestamp || null,
3340
+ message: {
3341
+ content: [{
3342
+ type: 'tool_use',
3343
+ name: String(payload.name || ''),
3344
+ input: parseToolInput(payload.arguments),
3345
+ }],
3346
+ },
3347
+ };
3348
+ lastEntryTsMs = ts;
3349
+ }
3350
+ }
3351
+ else if (payloadType === 'function_call_output') {
3352
+ const outputText = extractTextContent(payload.output);
3353
+ const pathMatches = outputText.match(/\/work\/[^\s:]+/g);
2931
3354
  if (pathMatches) {
2932
- for (const p of pathMatches.slice(0, 20)) { // cap to avoid perf issues
3355
+ for (const p of pathMatches.slice(0, 20)) {
2933
3356
  const clean = p.replace(/['"`;,)}\]]+$/, '');
2934
3357
  rememberFileEntry(accessedFiles, clean, ts, 'unknown');
2935
3358
  }
2936
3359
  }
2937
- for (const url of extractWebsiteUrlsFromText(text).slice(0, 30)) {
3360
+ for (const url of extractWebsiteUrlsFromText(outputText).slice(0, 30)) {
2938
3361
  rememberTimestampedEntry(accessedUrls, url, ts);
2939
3362
  }
2940
3363
  }
3364
+ else if (payloadType === 'message' && !lastEntry) {
3365
+ const role = String(payload.role || '').toLowerCase();
3366
+ if (role === 'assistant' || role === 'user') {
3367
+ lastEntry = {
3368
+ type: role,
3369
+ timestamp: obj.timestamp || null,
3370
+ message: { content: Array.isArray(payload.content) ? payload.content : [] },
3371
+ };
3372
+ lastEntryTsMs = ts;
3373
+ }
3374
+ }
3375
+ }
3376
+ // Also extract paths from tool_result content (file listings, etc.)
3377
+ if (entryType === 'user') {
3378
+ const message = (obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message))
3379
+ ? obj.message
3380
+ : null;
3381
+ const content = message?.content;
3382
+ if (Array.isArray(content)) {
3383
+ for (const block of content) {
3384
+ if (!block || typeof block !== 'object' || Array.isArray(block))
3385
+ continue;
3386
+ const typedBlock = block;
3387
+ if (typedBlock.type !== 'tool_result')
3388
+ continue;
3389
+ const blockText = extractTextContent(typedBlock.content);
3390
+ // Look for /work/ paths in tool output
3391
+ const pathMatches = blockText.match(/\/work\/[^\s:]+/g);
3392
+ if (pathMatches) {
3393
+ for (const p of pathMatches.slice(0, 20)) { // cap to avoid perf issues
3394
+ const clean = p.replace(/['"`;,)}\]]+$/, '');
3395
+ rememberFileEntry(accessedFiles, clean, ts, 'unknown');
3396
+ }
3397
+ }
3398
+ for (const url of extractWebsiteUrlsFromText(blockText).slice(0, 30)) {
3399
+ rememberTimestampedEntry(accessedUrls, url, ts);
3400
+ }
3401
+ }
3402
+ }
2941
3403
  }
2942
3404
  // Find the last real user prompt
2943
- if (!lastUserPrompt && obj.type === 'user' && !obj.isMeta) {
2944
- const content = obj.message?.content;
3405
+ if (!lastUserPrompt && entryType === 'user' && !obj.isMeta) {
3406
+ const message = (obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message))
3407
+ ? obj.message
3408
+ : null;
3409
+ const content = message?.content;
2945
3410
  if (typeof content === 'string' && content.length > 0) {
2946
3411
  if (!content.includes('<local-command-caveat>')) {
2947
3412
  lastUserPrompt = content.slice(0, 500);
2948
3413
  }
2949
3414
  }
2950
3415
  else if (Array.isArray(content)) {
2951
- const textBlock = content.find((b) => b.type === 'text' && b.text);
2952
- if (textBlock && !textBlock.text.includes('<local-command-caveat>')) {
3416
+ const textBlock = content.find((b) => {
3417
+ if (!b || typeof b !== 'object' || Array.isArray(b))
3418
+ return false;
3419
+ const typed = b;
3420
+ return typed.type === 'text' && typeof typed.text === 'string' && typed.text.length > 0;
3421
+ });
3422
+ if (textBlock && typeof textBlock.text === 'string' && !textBlock.text.includes('<local-command-caveat>')) {
2953
3423
  lastUserPrompt = textBlock.text.slice(0, 500);
2954
3424
  }
2955
3425
  }
2956
3426
  }
3427
+ // Codex transcript format: response_item.message entries carry prompts as input_text blocks.
3428
+ if (!lastUserPrompt && entryType === 'response_item' && payload) {
3429
+ const payloadType = String(payload.type || '').toLowerCase();
3430
+ const role = String(payload.role || '').toLowerCase();
3431
+ if (payloadType === 'message' && role === 'user') {
3432
+ const content = payload.content;
3433
+ if (typeof content === 'string' && !content.includes('<local-command-caveat>')) {
3434
+ lastUserPrompt = content.slice(0, 500);
3435
+ }
3436
+ else if (Array.isArray(content)) {
3437
+ const textBlock = content.find((b) => {
3438
+ if (!b || typeof b !== 'object' || Array.isArray(b))
3439
+ return false;
3440
+ const typed = b;
3441
+ if (typeof typed.text !== 'string' || typed.text.length === 0)
3442
+ return false;
3443
+ const blockType = String(typed.type || '');
3444
+ return blockType === 'input_text' || blockType === 'text';
3445
+ });
3446
+ if (textBlock && typeof textBlock.text === 'string' && !textBlock.text.includes('<local-command-caveat>')) {
3447
+ lastUserPrompt = textBlock.text.slice(0, 500);
3448
+ }
3449
+ }
3450
+ }
3451
+ }
3452
+ if (!lastEntry && (entryType === 'assistant' || entryType === 'user' || entryType === 'human')) {
3453
+ lastEntry = obj;
3454
+ lastEntryTsMs = ts;
3455
+ }
2957
3456
  }
2958
3457
  catch {
2959
3458
  // Line may be truncated at the start of our read window — skip it
@@ -2961,7 +3460,14 @@ function tailLastJsonlEntry(filePath) {
2961
3460
  }
2962
3461
  if (!lastEntry)
2963
3462
  return null;
2964
- return { entry: lastEntry, lastUserPrompt, accessedFiles, accessedUrls, mtimeMs: st.mtimeMs };
3463
+ return {
3464
+ entry: lastEntry,
3465
+ entryTsMs: lastEntryTsMs,
3466
+ lastUserPrompt,
3467
+ accessedFiles,
3468
+ accessedUrls,
3469
+ mtimeMs: st.mtimeMs,
3470
+ };
2965
3471
  }
2966
3472
  catch {
2967
3473
  return null;
@@ -3020,7 +3526,12 @@ function scanJsonlFiles(root, maxDepth = 4, maxEntries = JSONL_SCAN_MAX_ENTRIES)
3020
3526
  continue;
3021
3527
  }
3022
3528
  if (entry.endsWith('.jsonl')) {
3023
- files.push({ path: fullPath, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs });
3529
+ files.push({
3530
+ path: fullPath,
3531
+ mtimeMs: st.mtimeMs,
3532
+ ctimeMs: st.ctimeMs,
3533
+ birthtimeMs: st.birthtimeMs,
3534
+ });
3024
3535
  }
3025
3536
  }
3026
3537
  catch {
@@ -3034,6 +3545,81 @@ function scanJsonlFiles(root, maxDepth = 4, maxEntries = JSONL_SCAN_MAX_ENTRIES)
3034
3545
  }
3035
3546
  return files;
3036
3547
  }
3548
+ function normalizeFileTimestampMs(value) {
3549
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
3550
+ }
3551
+ function computeTranscriptTimestampOffsetMs(transcriptTsMs, fileMtimeMs) {
3552
+ const transcriptMs = normalizeFileTimestampMs(transcriptTsMs);
3553
+ const mtimeMs = normalizeFileTimestampMs(fileMtimeMs);
3554
+ if (!transcriptMs || !mtimeMs)
3555
+ return 0;
3556
+ const offsetMs = mtimeMs - transcriptMs;
3557
+ if (Math.abs(offsetMs) < (2 * 60 * 1000))
3558
+ return 0;
3559
+ if (Math.abs(offsetMs) > (12 * 60 * 60 * 1000))
3560
+ return 0;
3561
+ return offsetMs;
3562
+ }
3563
+ function applyTranscriptTimestampOffset(tsMs, offsetMs) {
3564
+ const ts = normalizeFileTimestampMs(tsMs);
3565
+ if (!ts || !offsetMs)
3566
+ return ts;
3567
+ const adjusted = ts + offsetMs;
3568
+ return adjusted > 0 ? adjusted : ts;
3569
+ }
3570
+ function getStableJsonlBirthtimeMs(file) {
3571
+ const birthtimeMs = normalizeFileTimestampMs(file.birthtimeMs);
3572
+ if (!birthtimeMs)
3573
+ return 0;
3574
+ const ctimeMs = normalizeFileTimestampMs(file.ctimeMs);
3575
+ const mtimeMs = normalizeFileTimestampMs(file.mtimeMs);
3576
+ if (mtimeMs && birthtimeMs > (mtimeMs + 1_000))
3577
+ return 0;
3578
+ // Some filesystems synthesize birthtime from ctime; reject that once the
3579
+ // file has clearly been modified after creation.
3580
+ if (ctimeMs &&
3581
+ Math.abs(birthtimeMs - ctimeMs) <= 1_000 &&
3582
+ mtimeMs &&
3583
+ Math.abs(mtimeMs - ctimeMs) > 1_000) {
3584
+ return 0;
3585
+ }
3586
+ return birthtimeMs;
3587
+ }
3588
+ function selectTranscriptFileForSession(sessionStartMs, jsonlFiles) {
3589
+ if (!Number.isFinite(sessionStartMs) || sessionStartMs <= 0 || jsonlFiles.length === 0) {
3590
+ return null;
3591
+ }
3592
+ const birthCandidates = jsonlFiles
3593
+ .map((file) => {
3594
+ const birthtimeMs = getStableJsonlBirthtimeMs(file);
3595
+ const mtimeMs = normalizeFileTimestampMs(file.mtimeMs);
3596
+ return {
3597
+ file,
3598
+ birthtimeMs,
3599
+ birthDelta: birthtimeMs > 0 ? Math.abs(birthtimeMs - sessionStartMs) : Number.POSITIVE_INFINITY,
3600
+ mtimeDelta: Math.abs(mtimeMs - sessionStartMs),
3601
+ };
3602
+ })
3603
+ .filter((entry) => entry.birthtimeMs > 0)
3604
+ .sort((a, b) => a.birthDelta - b.birthDelta
3605
+ || a.mtimeDelta - b.mtimeDelta
3606
+ || b.file.mtimeMs - a.file.mtimeMs);
3607
+ if (birthCandidates.length > 0) {
3608
+ return birthCandidates[0].file;
3609
+ }
3610
+ const futureByMtime = [...jsonlFiles]
3611
+ .filter((file) => normalizeFileTimestampMs(file.mtimeMs) >= sessionStartMs)
3612
+ .sort((a, b) => (normalizeFileTimestampMs(a.mtimeMs) - sessionStartMs)
3613
+ - (normalizeFileTimestampMs(b.mtimeMs) - sessionStartMs)
3614
+ || b.mtimeMs - a.mtimeMs);
3615
+ if (futureByMtime.length > 0) {
3616
+ return futureByMtime[0];
3617
+ }
3618
+ const closestByMtime = [...jsonlFiles].sort((a, b) => Math.abs(normalizeFileTimestampMs(a.mtimeMs) - sessionStartMs)
3619
+ - Math.abs(normalizeFileTimestampMs(b.mtimeMs) - sessionStartMs)
3620
+ || b.mtimeMs - a.mtimeMs);
3621
+ return closestByMtime[0] || null;
3622
+ }
3037
3623
  /**
3038
3624
  * Scan sandbox home for agent conversation JSONL files.
3039
3625
  * Results are cached briefly to keep `/api/sessions` responsive.
@@ -3068,33 +3654,34 @@ function findProjectJsonlFiles(agent) {
3068
3654
  */
3069
3655
  function extractToolDetailFromToolUseBlock(block) {
3070
3656
  const name = String(block.name || '');
3657
+ const normalizedName = name.toLowerCase();
3071
3658
  const inputRaw = block.input;
3072
3659
  const input = inputRaw && typeof inputRaw === 'object' && !Array.isArray(inputRaw)
3073
3660
  ? inputRaw
3074
3661
  : {};
3075
- if (name === 'Bash' || name === 'bash') {
3662
+ if (normalizedName === 'bash') {
3076
3663
  const cmd = String(input.command || '').slice(0, 60);
3077
3664
  return cmd ? `Ran \`${cmd}\`` : 'Running Bash';
3078
3665
  }
3079
- if (name === 'Edit' || name === 'edit') {
3666
+ if (normalizedName === 'edit' || normalizedName === 'multiedit') {
3080
3667
  const file = String(input.file_path || '').split('/').pop() || '';
3081
3668
  return file ? `Edited ${file}` : 'Editing a file';
3082
3669
  }
3083
- if (name === 'Read' || name === 'read') {
3670
+ if (normalizedName === 'read') {
3084
3671
  const file = String(input.file_path || '').split('/').pop() || '';
3085
3672
  return file ? `Read ${file}` : 'Reading a file';
3086
3673
  }
3087
- if (name === 'Write' || name === 'write') {
3674
+ if (normalizedName === 'write') {
3088
3675
  const file = String(input.file_path || '').split('/').pop() || '';
3089
3676
  return file ? `Wrote ${file}` : 'Writing a file';
3090
3677
  }
3091
- if (name === 'Grep' || name === 'grep') {
3678
+ if (normalizedName === 'grep') {
3092
3679
  return `Searching for "${String(input.pattern || '').slice(0, 40)}"`;
3093
3680
  }
3094
- if (name === 'Glob' || name === 'glob') {
3681
+ if (normalizedName === 'glob') {
3095
3682
  return `Finding files: ${String(input.pattern || '').slice(0, 40)}`;
3096
3683
  }
3097
- if (name === 'Task' || name === 'task') {
3684
+ if (normalizedName === 'task') {
3098
3685
  return 'Spawned subagent';
3099
3686
  }
3100
3687
  return `Using ${name}`;
@@ -3253,6 +3840,9 @@ function getAccessedFiles(accessedFiles, workdir) {
3253
3840
  if (!rel || seen.has(rel))
3254
3841
  continue;
3255
3842
  seen.add(rel);
3843
+ const mappedPath = p.startsWith('/work/')
3844
+ ? (workdir ? (0, path_1.join)(workdir, rel) : p)
3845
+ : p;
3256
3846
  let isDir = false;
3257
3847
  if (p.startsWith('/work/')) {
3258
3848
  const hostPath = (0, path_1.join)(workdir, rel);
@@ -3272,7 +3862,7 @@ function getAccessedFiles(accessedFiles, workdir) {
3272
3862
  }
3273
3863
  files.push({
3274
3864
  name: rel,
3275
- path: p,
3865
+ path: mappedPath,
3276
3866
  accessedAt: entry.ts,
3277
3867
  isDir,
3278
3868
  action: entry.action,
@@ -3352,7 +3942,7 @@ function pruneSessionActivityCache(now = Date.now()) {
3352
3942
  }
3353
3943
  /**
3354
3944
  * Determine an agent session's current activity by reading transcript JSONL.
3355
- * Correlates session → JSONL by matching session start time with file creation time.
3945
+ * Correlates session → JSONL by matching session start time with transcript creation time.
3356
3946
  */
3357
3947
  function getAgentActivity(session) {
3358
3948
  const unknown = createUnknownActivity();
@@ -3364,20 +3954,16 @@ function getAgentActivity(session) {
3364
3954
  const sessionStartMs = session.started ? new Date(session.started).getTime() : 0;
3365
3955
  if (!sessionStartMs)
3366
3956
  return unknown;
3367
- const sorted = [...jsonlFiles].sort((a, b) => b.mtimeMs - a.mtimeMs);
3368
- let bestFile = sorted.find(f => Math.abs(f.ctimeMs - sessionStartMs) < 5 * 60 * 1000);
3369
- if (!bestFile) {
3370
- bestFile = sorted.find(f => f.mtimeMs >= sessionStartMs);
3371
- }
3957
+ const bestFile = selectTranscriptFileForSession(sessionStartMs, jsonlFiles);
3372
3958
  if (!bestFile)
3373
3959
  return unknown;
3374
3960
  const result = tailLastJsonlEntry(bestFile.path);
3375
3961
  if (!result)
3376
3962
  return unknown;
3377
- const { entry, lastUserPrompt, accessedFiles, accessedUrls, mtimeMs } = result;
3963
+ const { entry, entryTsMs, lastUserPrompt, accessedFiles, accessedUrls, mtimeMs } = result;
3378
3964
  const age = Date.now() - mtimeMs;
3379
3965
  const type = entry.type || '';
3380
- const entryTs = entry.timestamp ? new Date(entry.timestamp).getTime() : mtimeMs;
3966
+ const entryTs = entryTsMs || mtimeMs;
3381
3967
  // Merge tail-read file accesses into persistent per-session history
3382
3968
  const mergedHistory = mergeFileHistory(session.id || '', accessedFiles);
3383
3969
  const files = getAccessedFiles(mergedHistory, session.workdir || '');
@@ -4038,57 +4624,554 @@ async function handleGetSessions(_req, res) {
4038
4624
  session.stats = s;
4039
4625
  }
4040
4626
  }
4041
- catch { /* stats unavailable */ }
4042
- // Annotate sessions with restart-required status
4043
- annotateRestartRequired(sessions);
4044
- json(res, { sessions });
4627
+ catch { /* stats unavailable */ }
4628
+ // Annotate sessions with restart-required status
4629
+ annotateRestartRequired(sessions);
4630
+ json(res, { sessions });
4631
+ }
4632
+ /**
4633
+ * Compare each session's config fingerprint against the current config.
4634
+ * Annotates sessions with `restartRequired` and `restartReasons` when they
4635
+ * are running with a stale mount configuration.
4636
+ */
4637
+ function annotateRestartRequired(sessions) {
4638
+ const config = (0, config_js_1.loadConfig)();
4639
+ const currentFingerprint = (0, container_js_1.computeMountFingerprint)(config);
4640
+ for (const session of sessions) {
4641
+ if (session.configFingerprint && session.configFingerprint !== currentFingerprint) {
4642
+ session.restartRequired = true;
4643
+ const reasons = [];
4644
+ if (session.image !== config.image)
4645
+ reasons.push('Container image changed');
4646
+ if (session.network !== config.network.mode)
4647
+ reasons.push('Network mode changed');
4648
+ if (reasons.length === 0)
4649
+ reasons.push('Mount config changed (datasets or paths)');
4650
+ session.restartReasons = reasons;
4651
+ }
4652
+ else {
4653
+ session.restartRequired = false;
4654
+ }
4655
+ }
4656
+ }
4657
+ function handleGetSecurity(_req, res) {
4658
+ const config = (0, config_js_1.loadConfig)();
4659
+ const blockedEvents = scanBlockedEvents();
4660
+ json(res, {
4661
+ blockedCommands: blockedEvents.slice(0, 20),
4662
+ blockedCount: blockedEvents.length,
4663
+ protection: {
4664
+ blacklistedCommands: config.commands.blacklist.length,
4665
+ blockedPatterns: config.filesystem.blocked_patterns.length,
4666
+ networkMode: config.network.mode,
4667
+ },
4668
+ });
4669
+ }
4670
+ function handleGetClaudeAuthStatus(_req, res) {
4671
+ const auth = readClaudeCredentialSnapshot();
4672
+ json(res, {
4673
+ ok: true,
4674
+ provider: 'claude',
4675
+ loggedIn: auth.loggedIn,
4676
+ email: auth.email,
4677
+ checkedAt: new Date().toISOString(),
4678
+ });
4679
+ }
4680
+ function isClaudeAuthHost(hostnameRaw) {
4681
+ const host = String(hostnameRaw || '').trim().toLowerCase();
4682
+ return host === 'claude.ai' || host === 'platform.claude.com' || host === 'console.anthropic.com';
4683
+ }
4684
+ function isLikelyClaudeAuthPath(pathnameRaw) {
4685
+ const path = String(pathnameRaw || '').trim().toLowerCase();
4686
+ if (!path)
4687
+ return false;
4688
+ return path.startsWith('/oauth')
4689
+ || path.startsWith('/login')
4690
+ || path.startsWith('/auth')
4691
+ || path.startsWith('/code/callback');
4692
+ }
4693
+ function maybeRewriteClaudeOauthRedirectUri(parsed) {
4694
+ const rawRedirectUri = (parsed.searchParams.get('redirect_uri') || '').trim();
4695
+ if (!rawRedirectUri)
4696
+ return;
4697
+ try {
4698
+ const redirectUri = new URL(rawRedirectUri);
4699
+ const host = String(redirectUri.hostname || '').trim().toLowerCase();
4700
+ if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1') {
4701
+ parsed.searchParams.set('redirect_uri', 'https://platform.claude.com/oauth/code/callback');
4702
+ }
4703
+ }
4704
+ catch {
4705
+ // Keep original redirect URI when parsing fails.
4706
+ }
4707
+ }
4708
+ function normalizeClaudeAuthUrlCandidate(candidate) {
4709
+ if (!candidate)
4710
+ return null;
4711
+ try {
4712
+ const parsed = new URL(String(candidate || '').trim());
4713
+ if (parsed.protocol !== 'https:')
4714
+ return null;
4715
+ if (!isClaudeAuthHost(parsed.hostname))
4716
+ return null;
4717
+ if (!isLikelyClaudeAuthPath(parsed.pathname))
4718
+ return null;
4719
+ if (parsed.hostname.toLowerCase() === 'claude.ai' && parsed.pathname === '/oauth/authorize') {
4720
+ maybeRewriteClaudeOauthRedirectUri(parsed);
4721
+ }
4722
+ return parsed.toString();
4723
+ }
4724
+ catch {
4725
+ return null;
4726
+ }
4727
+ }
4728
+ function normalizeClaudeOauthUrlFromText(raw) {
4729
+ const source = String(raw || '');
4730
+ const osc8Urls = [];
4731
+ const osc8Re = /\x1b\]8;[^\x07\x1b]*?;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
4732
+ for (const match of source.matchAll(osc8Re)) {
4733
+ const candidate = String(match[1] || '').trim();
4734
+ if (candidate)
4735
+ osc8Urls.push(candidate);
4736
+ }
4737
+ let cleaned = source
4738
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
4739
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
4740
+ .replace(/\x1b[P^_][\s\S]*?\x1b\\/g, '')
4741
+ .replace(/\x1b[@-_]/g, '')
4742
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
4743
+ if (osc8Urls.length) {
4744
+ cleaned += '\n' + osc8Urls.join('\n');
4745
+ }
4746
+ if (!cleaned)
4747
+ return null;
4748
+ const strictMatch = cleaned.match(/https:\/\/claude\.ai\/oauth\/authorize\?[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]+/);
4749
+ if (strictMatch && strictMatch[0]) {
4750
+ const strictUrl = normalizeClaudeAuthUrlCandidate(strictMatch[0]);
4751
+ if (strictUrl)
4752
+ return strictUrl;
4753
+ }
4754
+ const fallbackMatches = cleaned.match(CLAUDE_AUTH_FALLBACK_URL_RE) || [];
4755
+ for (const match of fallbackMatches) {
4756
+ const normalized = normalizeClaudeAuthUrlCandidate(match);
4757
+ if (normalized)
4758
+ return normalized;
4759
+ }
4760
+ return null;
4761
+ }
4762
+ function stripTerminalControlForClaudeAuthFlow(text) {
4763
+ return String(text || '')
4764
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
4765
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
4766
+ .replace(/\x1b[P^_][\s\S]*?\x1b\\/g, '')
4767
+ .replace(/\x1b[@-_]/g, '')
4768
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
4769
+ }
4770
+ function sanitizeClaudeOauthCodeForCli(raw) {
4771
+ let text = stripTerminalControlForClaudeAuthFlow(String(raw || ''))
4772
+ .replace(/^["'\s]+|["'\s]+$/g, '')
4773
+ .trim();
4774
+ if (!text)
4775
+ return '';
4776
+ const hashCodeMatch = text.match(/^([A-Za-z0-9._~-]{24,})#[A-Za-z0-9._~-]{6,}$/);
4777
+ if (hashCodeMatch && hashCodeMatch[1]) {
4778
+ return hashCodeMatch[1].trim();
4779
+ }
4780
+ const urlInText = text.match(/https?:\/\/[^\s"'<>]+/i);
4781
+ if (urlInText && urlInText[0]) {
4782
+ try {
4783
+ const parsedUrl = new URL(urlInText[0]);
4784
+ const codeFromUrl = (parsedUrl.searchParams.get('code') || '').trim();
4785
+ if (codeFromUrl)
4786
+ return codeFromUrl;
4787
+ }
4788
+ catch {
4789
+ // Continue with regex fallback below.
4790
+ }
4791
+ }
4792
+ const hashOnlyMatch = text.match(/^([A-Za-z0-9._~-]{24,})#/);
4793
+ if (hashOnlyMatch && hashOnlyMatch[1]) {
4794
+ return hashOnlyMatch[1].trim();
4795
+ }
4796
+ const codeMatch = text.match(/[?&#]code=([^&#\s]+)/i) || text.match(/\bcode=([^\s&]+)/i);
4797
+ if (codeMatch && codeMatch[1]) {
4798
+ try {
4799
+ return decodeURIComponent(codeMatch[1]).trim();
4800
+ }
4801
+ catch {
4802
+ return codeMatch[1].trim();
4803
+ }
4804
+ }
4805
+ return text;
4806
+ }
4807
+ function resolveClaudeCliBinaryForSandboxHome(sandboxHome) {
4808
+ const fromSandboxHome = (0, path_1.join)(sandboxHome, '.npm-global', 'bin', 'claude');
4809
+ if ((0, fs_1.existsSync)(fromSandboxHome))
4810
+ return fromSandboxHome;
4811
+ return 'claude';
4812
+ }
4813
+ function notifyClaudeAuthFlowUrlWaiters(flow, url) {
4814
+ if (!flow.urlWaiters.length)
4815
+ return;
4816
+ const waiters = flow.urlWaiters.splice(0, flow.urlWaiters.length);
4817
+ for (const waiter of waiters) {
4818
+ try {
4819
+ waiter(url);
4820
+ }
4821
+ catch { /* best effort */ }
4822
+ }
4823
+ }
4824
+ function notifyClaudeAuthFlowResultWaiters(flow, ok) {
4825
+ if (!flow.resultWaiters.length)
4826
+ return;
4827
+ const waiters = flow.resultWaiters.splice(0, flow.resultWaiters.length);
4828
+ for (const waiter of waiters) {
4829
+ try {
4830
+ waiter(ok);
4831
+ }
4832
+ catch { /* best effort */ }
4833
+ }
4834
+ }
4835
+ function pruneClaudeAuthLoginFlows() {
4836
+ const now = Date.now();
4837
+ for (const [sessionId, flow] of claudeAuthLoginFlows.entries()) {
4838
+ const ageMs = now - flow.updatedAtMs;
4839
+ if (!flow.finished && ageMs <= CLAUDE_AUTH_FLOW_IDLE_TTL_MS)
4840
+ continue;
4841
+ if (!flow.finished) {
4842
+ try {
4843
+ flow.child.kill('SIGTERM');
4844
+ }
4845
+ catch { /* best effort */ }
4846
+ }
4847
+ notifyClaudeAuthFlowUrlWaiters(flow, flow.url || null);
4848
+ notifyClaudeAuthFlowResultWaiters(flow, flow.success === true);
4849
+ claudeAuthLoginFlows.delete(sessionId);
4850
+ }
4851
+ }
4852
+ function waitForClaudeAuthFlowUrl(flow, timeoutMs) {
4853
+ if (flow.url)
4854
+ return Promise.resolve(flow.url);
4855
+ if (flow.finished)
4856
+ return Promise.resolve(null);
4857
+ return new Promise((resolve) => {
4858
+ const waiter = (url) => {
4859
+ clearTimeout(timeout);
4860
+ resolve(url || null);
4861
+ };
4862
+ const timeout = setTimeout(() => {
4863
+ const idx = flow.urlWaiters.indexOf(waiter);
4864
+ if (idx >= 0)
4865
+ flow.urlWaiters.splice(idx, 1);
4866
+ resolve(flow.url || null);
4867
+ }, Math.max(500, timeoutMs));
4868
+ flow.urlWaiters.push(waiter);
4869
+ });
4870
+ }
4871
+ function waitForClaudeAuthFlowResult(flow, timeoutMs) {
4872
+ if (flow.success)
4873
+ return Promise.resolve(true);
4874
+ if (flow.finished)
4875
+ return Promise.resolve(false);
4876
+ return new Promise((resolve) => {
4877
+ const waiter = (ok) => {
4878
+ clearTimeout(timeout);
4879
+ resolve(ok);
4880
+ };
4881
+ const timeout = setTimeout(() => {
4882
+ const idx = flow.resultWaiters.indexOf(waiter);
4883
+ if (idx >= 0)
4884
+ flow.resultWaiters.splice(idx, 1);
4885
+ resolve(flow.success === true);
4886
+ }, Math.max(500, timeoutMs));
4887
+ flow.resultWaiters.push(waiter);
4888
+ });
4889
+ }
4890
+ function summarizeClaudeAuthFlowFailure(flow) {
4891
+ const plainBuffer = stripTerminalControlForClaudeAuthFlow(flow.buffer);
4892
+ const plainStderr = stripTerminalControlForClaudeAuthFlow(flow.stderr);
4893
+ const combined = (plainStderr + '\n' + plainBuffer)
4894
+ .split(/\r?\n/)
4895
+ .map((line) => line.trim())
4896
+ .filter((line) => line.length > 0);
4897
+ if (!combined.length) {
4898
+ return 'Claude login link not detected. Try again, or run `claude auth login` directly in the attached terminal.';
4899
+ }
4900
+ const sample = combined.slice(-6).join(' | ').slice(0, 420);
4901
+ return `Claude login link not detected. Last output: ${sample}`;
4902
+ }
4903
+ function summarizeClaudeAuthFlowCodeFailure(flow) {
4904
+ const plainBuffer = stripTerminalControlForClaudeAuthFlow(flow.buffer);
4905
+ const plainStderr = stripTerminalControlForClaudeAuthFlow(flow.stderr);
4906
+ const combined = (plainStderr + '\n' + plainBuffer)
4907
+ .split(/\r?\n/)
4908
+ .map((line) => line.trim())
4909
+ .filter((line) => line.length > 0);
4910
+ if (!combined.length) {
4911
+ return 'Claude login confirmation was not detected. The code may be invalid or expired.';
4912
+ }
4913
+ const sample = combined.slice(-6).join(' | ').slice(0, 420);
4914
+ return `Claude login confirmation was not detected. Last output: ${sample}`;
4915
+ }
4916
+ function startClaudeAuthLoginFlow(sessionId, workdir) {
4917
+ pruneClaudeAuthLoginFlows();
4918
+ const existing = claudeAuthLoginFlows.get(sessionId);
4919
+ if (existing && !existing.finished)
4920
+ return existing;
4921
+ if (existing) {
4922
+ try {
4923
+ existing.child.kill('SIGTERM');
4924
+ }
4925
+ catch { /* best effort */ }
4926
+ claudeAuthLoginFlows.delete(sessionId);
4927
+ }
4928
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
4929
+ const claudeBinary = resolveClaudeCliBinaryForSandboxHome(sandboxHome);
4930
+ const env = {
4931
+ ...process.env,
4932
+ HOME: sandboxHome,
4933
+ };
4934
+ const child = (0, child_process_1.spawn)(claudeBinary, ['auth', 'login'], {
4935
+ cwd: workdir || sandboxHome,
4936
+ env,
4937
+ stdio: ['pipe', 'pipe', 'pipe'],
4938
+ });
4939
+ const flow = {
4940
+ sessionId,
4941
+ child,
4942
+ startedAtMs: Date.now(),
4943
+ updatedAtMs: Date.now(),
4944
+ buffer: '',
4945
+ stderr: '',
4946
+ url: null,
4947
+ finished: false,
4948
+ success: false,
4949
+ exitCode: null,
4950
+ codeSubmitted: false,
4951
+ urlWaiters: [],
4952
+ resultWaiters: [],
4953
+ };
4954
+ claudeAuthLoginFlows.set(sessionId, flow);
4955
+ const handleOutput = (text, isStderr) => {
4956
+ if (!text)
4957
+ return;
4958
+ flow.updatedAtMs = Date.now();
4959
+ if (isStderr) {
4960
+ flow.stderr = (flow.stderr + text).slice(-CLAUDE_AUTH_FLOW_MAX_OUTPUT_CHARS);
4961
+ }
4962
+ flow.buffer = (flow.buffer + text).slice(-CLAUDE_AUTH_FLOW_MAX_OUTPUT_CHARS);
4963
+ if (!flow.url) {
4964
+ const normalizedUrl = normalizeClaudeOauthUrlFromText(flow.buffer);
4965
+ if (normalizedUrl) {
4966
+ flow.url = normalizedUrl;
4967
+ notifyClaudeAuthFlowUrlWaiters(flow, normalizedUrl);
4968
+ }
4969
+ }
4970
+ if (!flow.success) {
4971
+ const plainChunk = stripTerminalControlForClaudeAuthFlow(text);
4972
+ if (CLAUDE_LOGIN_SUCCESS_RE.test(plainChunk)) {
4973
+ flow.success = true;
4974
+ notifyClaudeAuthFlowResultWaiters(flow, true);
4975
+ }
4976
+ }
4977
+ };
4978
+ child.stdout.on('data', (chunk) => {
4979
+ handleOutput(chunk.toString('utf-8'), false);
4980
+ });
4981
+ child.stderr.on('data', (chunk) => {
4982
+ handleOutput(chunk.toString('utf-8'), true);
4983
+ });
4984
+ child.on('error', (err) => {
4985
+ flow.finished = true;
4986
+ flow.updatedAtMs = Date.now();
4987
+ flow.stderr = (flow.stderr + '\n' + String(err.message || err)).slice(-CLAUDE_AUTH_FLOW_MAX_OUTPUT_CHARS);
4988
+ notifyClaudeAuthFlowUrlWaiters(flow, flow.url || null);
4989
+ notifyClaudeAuthFlowResultWaiters(flow, false);
4990
+ });
4991
+ child.on('close', (code) => {
4992
+ flow.finished = true;
4993
+ flow.updatedAtMs = Date.now();
4994
+ flow.exitCode = Number.isFinite(code) ? Math.trunc(code) : null;
4995
+ if (flow.codeSubmitted && !flow.success && flow.exitCode === 0) {
4996
+ flow.success = true;
4997
+ }
4998
+ notifyClaudeAuthFlowUrlWaiters(flow, flow.url || null);
4999
+ notifyClaudeAuthFlowResultWaiters(flow, flow.success === true);
5000
+ });
5001
+ return flow;
5002
+ }
5003
+ function resolveClaudeAuthSessionForLocalNode(sessionIdRaw) {
5004
+ const sessionId = String(sessionIdRaw || '').trim();
5005
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(sessionId)) {
5006
+ return { ok: false, status: 400, error: 'Invalid terminal session id.' };
5007
+ }
5008
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(sessionId);
5009
+ if (!record) {
5010
+ return { ok: false, status: 404, error: 'Terminal session not found.' };
5011
+ }
5012
+ if (record.node !== (0, os_1.hostname)()) {
5013
+ return {
5014
+ ok: false,
5015
+ status: 400,
5016
+ error: `Terminal session is on a different node (${record.node})`,
5017
+ };
5018
+ }
5019
+ if (String(record.agent || '').toLowerCase() !== 'claude') {
5020
+ return { ok: false, status: 400, error: 'Terminal session is not a Claude session.' };
5021
+ }
5022
+ return { ok: true, record };
5023
+ }
5024
+ async function handlePostClaudeAuthLoginStart(req, res) {
5025
+ try {
5026
+ const body = JSON.parse(await readBody(req) || '{}');
5027
+ const resolved = resolveClaudeAuthSessionForLocalNode(body.sessionId || '');
5028
+ if (!resolved.ok) {
5029
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5030
+ return;
5031
+ }
5032
+ const sessionId = resolved.record.id;
5033
+ const flow = startClaudeAuthLoginFlow(sessionId, resolved.record.workdir);
5034
+ const url = await waitForClaudeAuthFlowUrl(flow, CLAUDE_AUTH_FLOW_URL_WAIT_TIMEOUT_MS);
5035
+ if (url) {
5036
+ json(res, {
5037
+ ok: true,
5038
+ sessionId,
5039
+ url,
5040
+ });
5041
+ return;
5042
+ }
5043
+ json(res, {
5044
+ ok: false,
5045
+ sessionId,
5046
+ error: summarizeClaudeAuthFlowFailure(flow),
5047
+ }, 504);
5048
+ if (!flow.finished) {
5049
+ try {
5050
+ flow.child.kill('SIGTERM');
5051
+ }
5052
+ catch { /* best effort */ }
5053
+ }
5054
+ if (claudeAuthLoginFlows.get(sessionId) === flow) {
5055
+ claudeAuthLoginFlows.delete(sessionId);
5056
+ }
5057
+ }
5058
+ catch (err) {
5059
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5060
+ }
5061
+ }
5062
+ async function handlePostClaudeAuthLoginCode(req, res) {
5063
+ try {
5064
+ const body = JSON.parse(await readBody(req) || '{}');
5065
+ const resolved = resolveClaudeAuthSessionForLocalNode(body.sessionId || '');
5066
+ if (!resolved.ok) {
5067
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5068
+ return;
5069
+ }
5070
+ const sessionId = resolved.record.id;
5071
+ const code = sanitizeClaudeOauthCodeForCli(String(body.code || ''));
5072
+ if (!code) {
5073
+ json(res, { ok: false, error: 'No login code detected.' }, 400);
5074
+ return;
5075
+ }
5076
+ const flow = claudeAuthLoginFlows.get(sessionId);
5077
+ if (!flow || flow.finished) {
5078
+ json(res, {
5079
+ ok: false,
5080
+ error: 'No active Claude login flow. Start /login again to request a fresh link.',
5081
+ }, 409);
5082
+ return;
5083
+ }
5084
+ flow.codeSubmitted = true;
5085
+ flow.updatedAtMs = Date.now();
5086
+ try {
5087
+ flow.child.stdin.write(code + '\n');
5088
+ }
5089
+ catch (err) {
5090
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5091
+ return;
5092
+ }
5093
+ const confirmed = await waitForClaudeAuthFlowResult(flow, CLAUDE_AUTH_FLOW_CONFIRM_TIMEOUT_MS);
5094
+ if (confirmed) {
5095
+ flow.success = true;
5096
+ flow.updatedAtMs = Date.now();
5097
+ try {
5098
+ flow.child.kill('SIGTERM');
5099
+ }
5100
+ catch { /* best effort */ }
5101
+ claudeAuthLoginFlows.delete(sessionId);
5102
+ json(res, { ok: true, sessionId, confirmed: true });
5103
+ return;
5104
+ }
5105
+ json(res, {
5106
+ ok: false,
5107
+ sessionId,
5108
+ error: summarizeClaudeAuthFlowCodeFailure(flow),
5109
+ }, 504);
5110
+ }
5111
+ catch (err) {
5112
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5113
+ }
4045
5114
  }
4046
- /**
4047
- * Compare each session's config fingerprint against the current config.
4048
- * Annotates sessions with `restartRequired` and `restartReasons` when they
4049
- * are running with a stale mount configuration.
4050
- */
4051
- function annotateRestartRequired(sessions) {
4052
- const config = (0, config_js_1.loadConfig)();
4053
- const currentFingerprint = (0, container_js_1.computeMountFingerprint)(config);
4054
- for (const session of sessions) {
4055
- if (session.configFingerprint && session.configFingerprint !== currentFingerprint) {
4056
- session.restartRequired = true;
4057
- const reasons = [];
4058
- if (session.image !== config.image)
4059
- reasons.push('Container image changed');
4060
- if (session.network !== config.network.mode)
4061
- reasons.push('Network mode changed');
4062
- if (reasons.length === 0)
4063
- reasons.push('Mount config changed (datasets or paths)');
4064
- session.restartReasons = reasons;
5115
+ function readClaudeOauthBrowserUrlSnapshot(consume, minUpdatedAtMs) {
5116
+ const candidates = [];
5117
+ for (const filePath of CLAUDE_BROWSER_URL_FILES) {
5118
+ if (!(0, fs_1.existsSync)(filePath))
5119
+ continue;
5120
+ try {
5121
+ const st = (0, fs_1.statSync)(filePath);
5122
+ const ageMs = Date.now() - st.mtimeMs;
5123
+ if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > CLAUDE_BROWSER_URL_MAX_AGE_MS)
5124
+ continue;
5125
+ if (minUpdatedAtMs !== null && minUpdatedAtMs > 0 && st.mtimeMs < minUpdatedAtMs) {
5126
+ continue;
5127
+ }
5128
+ const raw = (0, fs_1.readFileSync)(filePath, 'utf-8');
5129
+ candidates.push({
5130
+ path: filePath,
5131
+ mtimeMs: st.mtimeMs,
5132
+ updatedAt: new Date(st.mtimeMs).toISOString(),
5133
+ raw,
5134
+ });
4065
5135
  }
4066
- else {
4067
- session.restartRequired = false;
5136
+ catch {
5137
+ // Skip unreadable candidates.
4068
5138
  }
4069
5139
  }
4070
- }
4071
- function handleGetSecurity(_req, res) {
4072
- const config = (0, config_js_1.loadConfig)();
4073
- const blockedEvents = scanBlockedEvents();
4074
- json(res, {
4075
- blockedCommands: blockedEvents.slice(0, 20),
4076
- blockedCount: blockedEvents.length,
4077
- protection: {
4078
- blacklistedCommands: config.commands.blacklist.length,
4079
- blockedPatterns: config.filesystem.blocked_patterns.length,
4080
- networkMode: config.network.mode,
4081
- },
4082
- });
4083
- }
4084
- function handleGetClaudeAuthStatus(_req, res) {
4085
- const auth = readClaudeCredentialSnapshot();
5140
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
5141
+ let latestMeta = { updatedAt: null, path: null };
5142
+ for (const candidate of candidates) {
5143
+ latestMeta = { updatedAt: candidate.updatedAt, path: candidate.path };
5144
+ const normalizedUrl = normalizeClaudeOauthUrlFromText(candidate.raw);
5145
+ if (!normalizedUrl)
5146
+ continue;
5147
+ if (consume) {
5148
+ try {
5149
+ (0, fs_1.unlinkSync)(candidate.path);
5150
+ }
5151
+ catch { /* best effort */ }
5152
+ }
5153
+ return {
5154
+ url: normalizedUrl,
5155
+ updatedAt: candidate.updatedAt,
5156
+ path: candidate.path,
5157
+ };
5158
+ }
5159
+ return { url: null, updatedAt: latestMeta.updatedAt, path: latestMeta.path };
5160
+ }
5161
+ function handleGetClaudeOauthUrl(reqUrl, res) {
5162
+ const consumeRaw = String(reqUrl.searchParams.get('consume') || '').trim().toLowerCase();
5163
+ const consume = consumeRaw === '1' || consumeRaw === 'true' || consumeRaw === 'yes';
5164
+ const minUpdatedAtRaw = String(reqUrl.searchParams.get('minUpdatedAtMs') || '').trim();
5165
+ const parsedMinUpdatedAtMs = minUpdatedAtRaw ? Number(minUpdatedAtRaw) : NaN;
5166
+ const minUpdatedAtMs = Number.isFinite(parsedMinUpdatedAtMs) && parsedMinUpdatedAtMs > 0
5167
+ ? Math.floor(parsedMinUpdatedAtMs)
5168
+ : null;
5169
+ const snapshot = readClaudeOauthBrowserUrlSnapshot(consume, minUpdatedAtMs);
4086
5170
  json(res, {
4087
5171
  ok: true,
4088
- provider: 'claude',
4089
- loggedIn: auth.loggedIn,
4090
- email: auth.email,
4091
- checkedAt: new Date().toISOString(),
5172
+ url: snapshot.url,
5173
+ updatedAt: snapshot.updatedAt,
5174
+ path: snapshot.path,
4092
5175
  });
4093
5176
  }
4094
5177
  async function handleStopSession(req, res) {
@@ -4517,8 +5600,12 @@ async function handleGetWebTerminalSessions(res) {
4517
5600
  return Number.isFinite(ms) ? ms : 0;
4518
5601
  }
4519
5602
  const allSessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
4520
- const activeSessions = allSessions.filter((session) => session.status === 'running');
4521
- const recentInactive = allSessions
5603
+ const visibleSessions = allSessions.filter((session) => {
5604
+ const sessionNode = String(session.node || '').trim();
5605
+ return !sessionNode || sessionNode === localNode;
5606
+ });
5607
+ const activeSessions = visibleSessions.filter((session) => session.status === 'running');
5608
+ const recentInactive = visibleSessions
4522
5609
  .filter((session) => session.status !== 'running')
4523
5610
  .sort((a, b) => parseSessionSortMs(b) - parseSessionSortMs(a))
4524
5611
  .slice(0, 24);
@@ -4529,21 +5616,107 @@ async function handleGetWebTerminalSessions(res) {
4529
5616
  .map((job) => serializeWebTerminalInitJob(job));
4530
5617
  json(res, { ok: true, sessions, initJobs });
4531
5618
  }
4532
- async function handleGetWebTerminalHistory(reqUrl, res) {
4533
- const id = String(reqUrl.searchParams.get('id') || '').trim();
5619
+ async function getTmuxSessionForegroundCommand(tmuxSession) {
5620
+ const target = String(tmuxSession || '').trim();
5621
+ if (!target)
5622
+ return '';
5623
+ try {
5624
+ const tmuxBin = await (0, web_terminal_js_1.getTmuxBinary)();
5625
+ const { stdout } = await execFileAsync(tmuxBin, ['list-panes', '-t', target, '-F', '#{pane_active}\t#{pane_current_command}'], { timeout: 3_000 });
5626
+ const lines = String(stdout || '').split(/\r?\n/);
5627
+ let fallback = '';
5628
+ for (const line of lines) {
5629
+ if (!line)
5630
+ continue;
5631
+ const tabIndex = line.indexOf('\t');
5632
+ const active = tabIndex >= 0 ? line.slice(0, tabIndex).trim() : '';
5633
+ const command = tabIndex >= 0 ? line.slice(tabIndex + 1).trim() : line.trim();
5634
+ if (!command)
5635
+ continue;
5636
+ if (!fallback)
5637
+ fallback = command;
5638
+ if (active === '1')
5639
+ return command;
5640
+ }
5641
+ return fallback;
5642
+ }
5643
+ catch {
5644
+ return '';
5645
+ }
5646
+ }
5647
+ function resolveLocalWebTerminalRecord(idRaw) {
5648
+ const id = String(idRaw || '').trim();
4534
5649
  if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
4535
- json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
4536
- return;
5650
+ return { ok: false, status: 400, error: 'Invalid terminal session id' };
4537
5651
  }
4538
5652
  const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
4539
5653
  if (!record) {
4540
- json(res, { ok: false, error: 'Terminal session not found' }, 404);
4541
- return;
5654
+ return { ok: false, status: 404, error: 'Terminal session not found' };
4542
5655
  }
4543
5656
  if (record.node !== (0, os_1.hostname)()) {
4544
- json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
5657
+ return { ok: false, status: 400, error: `Terminal session is on a different node (${record.node})` };
5658
+ }
5659
+ return { ok: true, record };
5660
+ }
5661
+ function normalizeBookmarkField(raw, maxLength) {
5662
+ const text = typeof raw === 'string' ? raw.trim() : '';
5663
+ if (!text)
5664
+ return '';
5665
+ return text.slice(0, maxLength);
5666
+ }
5667
+ function normalizeBookmarkSeq(raw) {
5668
+ if (raw === null || raw === undefined || raw === '')
5669
+ return null;
5670
+ const value = Number(raw);
5671
+ if (!Number.isFinite(value))
5672
+ return null;
5673
+ return Math.max(0, Math.floor(value));
5674
+ }
5675
+ function normalizeBookmarkViewportHint(raw) {
5676
+ const value = Number(raw);
5677
+ if (!Number.isFinite(value))
5678
+ return 0;
5679
+ return Math.max(0, Math.floor(value));
5680
+ }
5681
+ function normalizeBookmarkHash(raw) {
5682
+ return normalizeBookmarkField(raw, 128);
5683
+ }
5684
+ function stableBookmarkHash(parts) {
5685
+ const normalized = parts
5686
+ .map((part) => String(part || '').trim().replace(/\s+/g, ' ').toLowerCase())
5687
+ .filter(Boolean)
5688
+ .join('\n');
5689
+ if (!normalized)
5690
+ return '';
5691
+ return (0, crypto_1.createHash)('sha1').update(normalized).digest('hex');
5692
+ }
5693
+ function isTerminalBookmarksFeatureEnabled() {
5694
+ return !TEMPORARILY_DISABLED_WEB_UI_FEATURES.terminalBookmarks;
5695
+ }
5696
+ function serializeWebTerminalBookmark(bookmark) {
5697
+ return {
5698
+ id: bookmark.id,
5699
+ sessionId: bookmark.sessionId,
5700
+ label: bookmark.label || '',
5701
+ createdAt: bookmark.createdAt,
5702
+ anchorText: bookmark.anchorText,
5703
+ previewText: bookmark.previewText,
5704
+ viewportYHint: bookmark.viewportYHint,
5705
+ aroundTopText: bookmark.aroundTopText || '',
5706
+ aroundBottomText: bookmark.aroundBottomText || '',
5707
+ latestSeqAtCapture: bookmark.latestSeqAtCapture,
5708
+ oldestSeqLoadedAtCapture: bookmark.oldestSeqLoadedAtCapture,
5709
+ anchorHash: bookmark.anchorHash || '',
5710
+ };
5711
+ }
5712
+ async function handleGetWebTerminalHistory(reqUrl, res) {
5713
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
5714
+ const resolvedRecord = resolveLocalWebTerminalRecord(id);
5715
+ if (!resolvedRecord.ok) {
5716
+ json(res, { ok: false, error: resolvedRecord.error }, resolvedRecord.status);
4545
5717
  return;
4546
5718
  }
5719
+ const record = resolvedRecord.record;
4547
5720
  const beforeRaw = String(reqUrl.searchParams.get('before') || '').trim();
4548
5721
  let beforeSeq = null;
4549
5722
  if (beforeRaw) {
@@ -4565,20 +5738,124 @@ async function handleGetWebTerminalHistory(reqUrl, res) {
4565
5738
  limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(parsedLimit)));
4566
5739
  }
4567
5740
  const bridge = await ensureWebTerminalBridge(record);
5741
+ const currentCommand = await getTmuxSessionForegroundCommand(record.tmuxSession);
4568
5742
  if (!bridge) {
4569
- json(res, { ok: false, error: 'Could not open terminal bridge' }, 500);
5743
+ json(res, {
5744
+ ok: true,
5745
+ id: record.id,
5746
+ currentCommand: currentCommand || null,
5747
+ history: {
5748
+ chunks: [],
5749
+ hasMore: false,
5750
+ nextBefore: null,
5751
+ oldestSeq: null,
5752
+ latestSeq: null,
5753
+ limit,
5754
+ },
5755
+ });
4570
5756
  return;
4571
5757
  }
4572
5758
  const page = getWebTerminalHistoryPage(bridge, { beforeSeq, limit });
4573
5759
  json(res, {
4574
5760
  ok: true,
4575
5761
  id: record.id,
5762
+ currentCommand: currentCommand || null,
4576
5763
  history: {
4577
5764
  ...page,
4578
5765
  limit,
4579
5766
  },
4580
5767
  });
4581
5768
  }
5769
+ async function handleGetWebTerminalBookmarks(reqUrl, res) {
5770
+ if (!isTerminalBookmarksFeatureEnabled()) {
5771
+ json(res, { ok: false, error: 'Terminal bookmarks are temporarily disabled.' }, 404);
5772
+ return;
5773
+ }
5774
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
5775
+ const resolved = resolveLocalWebTerminalRecord(id);
5776
+ if (!resolved.ok) {
5777
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5778
+ return;
5779
+ }
5780
+ const bookmarks = (0, web_terminal_js_1.readWebTerminalBookmarks)(resolved.record.id).map(serializeWebTerminalBookmark);
5781
+ json(res, { ok: true, id: resolved.record.id, bookmarks });
5782
+ }
5783
+ async function handlePostWebTerminalBookmarks(req, res) {
5784
+ if (!isTerminalBookmarksFeatureEnabled()) {
5785
+ json(res, { ok: false, error: 'Terminal bookmarks are temporarily disabled.' }, 404);
5786
+ return;
5787
+ }
5788
+ try {
5789
+ const body = await readBody(req);
5790
+ const parsed = JSON.parse(body || '{}');
5791
+ const id = String(parsed.id || '').trim();
5792
+ const resolved = resolveLocalWebTerminalRecord(id);
5793
+ if (!resolved.ok) {
5794
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5795
+ return;
5796
+ }
5797
+ const anchorText = normalizeBookmarkField(parsed.anchorText, 1_200);
5798
+ if (!anchorText) {
5799
+ json(res, { ok: false, error: 'anchorText is required' }, 400);
5800
+ return;
5801
+ }
5802
+ const previewText = normalizeBookmarkField(parsed.previewText, 2_000) || anchorText;
5803
+ const label = normalizeBookmarkField(parsed.label, 160);
5804
+ const aroundTopText = normalizeBookmarkField(parsed.aroundTopText, 500);
5805
+ const aroundBottomText = normalizeBookmarkField(parsed.aroundBottomText, 500);
5806
+ const latestSeqAtCapture = normalizeBookmarkSeq(parsed.latestSeqAtCapture);
5807
+ const oldestSeqLoadedAtCapture = normalizeBookmarkSeq(parsed.oldestSeqLoadedAtCapture);
5808
+ const viewportYHint = normalizeBookmarkViewportHint(parsed.viewportYHint);
5809
+ const anchorHash = normalizeBookmarkHash(parsed.anchorHash)
5810
+ || stableBookmarkHash([aroundTopText, anchorText, aroundBottomText]);
5811
+ const result = (0, web_terminal_js_1.addWebTerminalBookmark)(resolved.record.id, {
5812
+ ...(label ? { label } : {}),
5813
+ anchorText,
5814
+ previewText,
5815
+ viewportYHint,
5816
+ ...(aroundTopText ? { aroundTopText } : {}),
5817
+ ...(aroundBottomText ? { aroundBottomText } : {}),
5818
+ latestSeqAtCapture,
5819
+ oldestSeqLoadedAtCapture,
5820
+ ...(anchorHash ? { anchorHash } : {}),
5821
+ });
5822
+ if (!result.ok) {
5823
+ json(res, { ok: false, error: result.error, code: result.code }, 404);
5824
+ return;
5825
+ }
5826
+ json(res, {
5827
+ ok: true,
5828
+ id: resolved.record.id,
5829
+ bookmark: serializeWebTerminalBookmark(result.bookmark),
5830
+ });
5831
+ }
5832
+ catch (err) {
5833
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5834
+ }
5835
+ }
5836
+ async function handleDeleteWebTerminalBookmarks(reqUrl, res) {
5837
+ if (!isTerminalBookmarksFeatureEnabled()) {
5838
+ json(res, { ok: false, error: 'Terminal bookmarks are temporarily disabled.' }, 404);
5839
+ return;
5840
+ }
5841
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
5842
+ const bookmarkId = String(reqUrl.searchParams.get('bookmarkId') || '').trim();
5843
+ if (!bookmarkId) {
5844
+ json(res, { ok: false, error: 'bookmarkId is required' }, 400);
5845
+ return;
5846
+ }
5847
+ const resolved = resolveLocalWebTerminalRecord(id);
5848
+ if (!resolved.ok) {
5849
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5850
+ return;
5851
+ }
5852
+ const result = (0, web_terminal_js_1.deleteWebTerminalBookmark)(resolved.record.id, bookmarkId);
5853
+ if (!result.ok) {
5854
+ json(res, { ok: false, error: result.error, code: result.code }, 404);
5855
+ return;
5856
+ }
5857
+ json(res, { ok: true, id: resolved.record.id, bookmarkId });
5858
+ }
4582
5859
  async function handlePostWebTerminalRename(req, res) {
4583
5860
  try {
4584
5861
  const body = await readBody(req);
@@ -4605,6 +5882,37 @@ async function handlePostWebTerminalRename(req, res) {
4605
5882
  json(res, { ok: false, error: err?.message ?? String(err) }, 500);
4606
5883
  }
4607
5884
  }
5885
+ async function handlePostWebTerminalStar(req, res) {
5886
+ try {
5887
+ const body = await readBody(req);
5888
+ const parsed = JSON.parse(body || '{}');
5889
+ const id = String(parsed.id || '').trim();
5890
+ const starred = parsed.starred === true;
5891
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
5892
+ json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
5893
+ return;
5894
+ }
5895
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
5896
+ if (!record) {
5897
+ json(res, { ok: false, error: 'Terminal session not found' }, 404);
5898
+ return;
5899
+ }
5900
+ if (record.node !== (0, os_1.hostname)()) {
5901
+ json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
5902
+ return;
5903
+ }
5904
+ const result = (0, web_terminal_js_1.setWebTerminalRecordStarred)(id, starred);
5905
+ if (!result.ok) {
5906
+ const status = result.code === 'not_found' ? 404 : 400;
5907
+ json(res, { ok: false, error: result.error, code: result.code }, status);
5908
+ return;
5909
+ }
5910
+ json(res, { ok: true, session: serializeWebTerminalSession(result.record) });
5911
+ }
5912
+ catch (err) {
5913
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5914
+ }
5915
+ }
4608
5916
  async function handlePostWebTerminalStop(req, res) {
4609
5917
  try {
4610
5918
  const body = await readBody(req);
@@ -5043,6 +6351,103 @@ function handleGetDatasetStats(_req, res) {
5043
6351
  }
5044
6352
  json(res, { stats });
5045
6353
  }
6354
+ function handleGetDatasetPreview(reqUrl, res) {
6355
+ const name = String(reqUrl.searchParams.get('name') || '').trim();
6356
+ const maxRows = Math.min(Math.max(1, Number(reqUrl.searchParams.get('rows')) || 5), 20);
6357
+ if (!name) {
6358
+ json(res, { ok: false, error: 'Missing name parameter' }, 400);
6359
+ return;
6360
+ }
6361
+ const config = (0, config_js_1.loadConfig)();
6362
+ const ds = (config.datasets || []).find((d) => d.name && String(d.name).toLowerCase() === name.toLowerCase());
6363
+ if (!ds) {
6364
+ json(res, { ok: false, error: `Dataset "${name}" not found` }, 404);
6365
+ return;
6366
+ }
6367
+ const hostPath = ds.path
6368
+ ? String(ds.path).replace(/^~/, (0, os_1.homedir)())
6369
+ : '';
6370
+ if (!hostPath || !(0, fs_1.existsSync)(hostPath)) {
6371
+ json(res, { ok: false, error: 'Dataset path not found' }, 404);
6372
+ return;
6373
+ }
6374
+ let entries;
6375
+ try {
6376
+ entries = (0, fs_1.readdirSync)(hostPath);
6377
+ }
6378
+ catch {
6379
+ json(res, { ok: false, error: 'Cannot read dataset directory' }, 500);
6380
+ return;
6381
+ }
6382
+ // Find first CSV/TSV/TXT file
6383
+ const csvTsvFile = entries.find((f) => {
6384
+ const lower = f.toLowerCase();
6385
+ return lower.endsWith('.csv') || lower.endsWith('.tsv') || lower.endsWith('.txt');
6386
+ });
6387
+ if (!csvTsvFile) {
6388
+ json(res, { ok: false, error: 'No CSV/TSV file found in dataset' }, 404);
6389
+ return;
6390
+ }
6391
+ const filePath = (0, path_1.join)(hostPath, csvTsvFile);
6392
+ let fileStat;
6393
+ try {
6394
+ fileStat = (0, fs_1.statSync)(filePath);
6395
+ }
6396
+ catch {
6397
+ json(res, { ok: false, error: 'Cannot stat file' }, 500);
6398
+ return;
6399
+ }
6400
+ if (!fileStat.isFile()) {
6401
+ json(res, { ok: false, error: 'Not a file' }, 400);
6402
+ return;
6403
+ }
6404
+ // Read first chunk (limit to 64KB)
6405
+ const MAX_PREVIEW_BYTES = 65536;
6406
+ let fd;
6407
+ try {
6408
+ fd = (0, fs_1.openSync)(filePath, 'r');
6409
+ }
6410
+ catch {
6411
+ json(res, { ok: false, error: 'Cannot open file' }, 500);
6412
+ return;
6413
+ }
6414
+ const readSize = Math.min(fileStat.size, MAX_PREVIEW_BYTES);
6415
+ const buffer = Buffer.alloc(readSize);
6416
+ let bytesRead = 0;
6417
+ try {
6418
+ bytesRead = (0, fs_1.readSync)(fd, buffer, 0, readSize, 0);
6419
+ }
6420
+ catch {
6421
+ json(res, { ok: false, error: 'Cannot read file' }, 500);
6422
+ return;
6423
+ }
6424
+ finally {
6425
+ try {
6426
+ (0, fs_1.closeSync)(fd);
6427
+ }
6428
+ catch { /* noop */ }
6429
+ }
6430
+ const content = buffer.subarray(0, bytesRead).toString('utf-8');
6431
+ const lines = content.split(/\r?\n/).filter((l) => l.trim());
6432
+ if (lines.length === 0) {
6433
+ json(res, { ok: false, error: 'File is empty' }, 400);
6434
+ return;
6435
+ }
6436
+ const delimiter = csvTsvFile.toLowerCase().endsWith('.tsv') ? '\t' : ',';
6437
+ const columns = lines[0].split(delimiter).map((c) => c.trim().replace(/^["']|["']$/g, ''));
6438
+ const rows = [];
6439
+ for (let i = 1; i < lines.length && rows.length < maxRows; i++) {
6440
+ const cells = lines[i].split(delimiter).map((c) => c.trim().replace(/^["']|["']$/g, ''));
6441
+ rows.push(cells);
6442
+ }
6443
+ json(res, {
6444
+ ok: true,
6445
+ columns,
6446
+ rows,
6447
+ file: csvTsvFile,
6448
+ total_rows: bytesRead < fileStat.size ? null : (lines.length - 1),
6449
+ });
6450
+ }
5046
6451
  async function handlePostDatasetScan(req, res) {
5047
6452
  try {
5048
6453
  const body = await readBody(req);
@@ -5052,20 +6457,7 @@ async function handlePostDatasetScan(req, res) {
5052
6457
  return;
5053
6458
  }
5054
6459
  const configPath = (0, config_js_1.getConfigPath)();
5055
- let obj = {};
5056
- if ((0, fs_1.existsSync)(configPath)) {
5057
- const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
5058
- const stripped = rawText
5059
- .split('\n')
5060
- .filter(line => !line.trimStart().startsWith('//'))
5061
- .join('\n');
5062
- try {
5063
- obj = JSON.parse(stripped);
5064
- }
5065
- catch {
5066
- obj = {};
5067
- }
5068
- }
6460
+ const obj = (0, config_js_1.readRawConfigFile)(configPath);
5069
6461
  const datasets = (obj.datasets || []);
5070
6462
  const idx = datasets.findIndex((d) => d?.name && String(d.name).toLowerCase() === name.toLowerCase());
5071
6463
  if (idx < 0) {
@@ -5087,8 +6479,7 @@ async function handlePostDatasetScan(req, res) {
5087
6479
  };
5088
6480
  ds.stats = statsObj;
5089
6481
  obj.datasets = datasets;
5090
- (0, fs_1.writeFileSync)(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
5091
- (0, config_js_1.ensurePrivateFile)(configPath);
6482
+ (0, config_js_1.writeRawConfigFile)(obj, configPath);
5092
6483
  json(res, { ok: true, stats: statsObj });
5093
6484
  }
5094
6485
  catch (err) {
@@ -5120,20 +6511,7 @@ async function handlePostDatasetExampleInstall(req, res) {
5120
6511
  const datasetMode = parsed.mode === 'rw' ? 'rw' : 'ro';
5121
6512
  const sourceUrl = resolveIrisSampleSourceUrl();
5122
6513
  const configPath = (0, config_js_1.getConfigPath)();
5123
- let obj = {};
5124
- if ((0, fs_1.existsSync)(configPath)) {
5125
- const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
5126
- const stripped = rawText
5127
- .split('\n')
5128
- .filter((line) => !line.trimStart().startsWith('//'))
5129
- .join('\n');
5130
- try {
5131
- obj = JSON.parse(stripped);
5132
- }
5133
- catch {
5134
- obj = {};
5135
- }
5136
- }
6514
+ const obj = (0, config_js_1.readRawConfigFile)(configPath);
5137
6515
  const datasets = Array.isArray(obj.datasets) ? obj.datasets : [];
5138
6516
  const byNameConflict = datasets.find((d) => {
5139
6517
  const n = typeof d.name === 'string' ? d.name : '';
@@ -5201,8 +6579,7 @@ async function handlePostDatasetExampleInstall(req, res) {
5201
6579
  };
5202
6580
  datasets.push(entry);
5203
6581
  obj.datasets = datasets;
5204
- (0, fs_1.writeFileSync)(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
5205
- (0, config_js_1.ensurePrivateFile)(configPath);
6582
+ (0, config_js_1.writeRawConfigFile)(obj, configPath);
5206
6583
  json(res, {
5207
6584
  ok: true,
5208
6585
  dataset: entry,
@@ -5453,16 +6830,13 @@ function collectMcpState() {
5453
6830
  const slurmPluginEnabled = isPluginEnabledInConfig(config, 'slurm');
5454
6831
  const slurmConfigured = slurmPluginEnabled && config.slurm.enabled && config.slurm.mcp_server;
5455
6832
  const clusterConfigured = slurmPluginEnabled && config.slurm.enabled && config.slurm.mcp_server;
5456
- const datasetsConfigured = true;
5457
- const resultsConfigured = true;
6833
+ const datasetsConfigured = isPluginEnabledInConfig(config, 'datasets');
5458
6834
  const slurmEntry = registeredServers['labgate-slurm'];
5459
6835
  const clusterEntry = registeredServers['labgate-cluster'];
5460
6836
  const datasetsEntry = registeredServers['labgate-datasets'];
5461
- const resultsEntry = registeredServers['labgate-results'];
5462
6837
  const slurmState = inferServerState('labgate-slurm', slurmConfigured, slurmEntry, sandboxHome);
5463
6838
  const clusterState = inferServerState('labgate-cluster', clusterConfigured, clusterEntry, sandboxHome);
5464
6839
  const datasetsState = inferServerState('labgate-datasets', datasetsConfigured, datasetsEntry, sandboxHome);
5465
- const resultsState = inferServerState('labgate-results', resultsConfigured, resultsEntry, sandboxHome);
5466
6840
  const servers = [
5467
6841
  {
5468
6842
  id: 'labgate-slurm',
@@ -5536,35 +6910,6 @@ function collectMcpState() {
5536
6910
  { name: 'unregister_dataset', title: 'Unregister Dataset', description: 'Remove a dataset from the config' },
5537
6911
  ],
5538
6912
  },
5539
- {
5540
- id: 'labgate-results',
5541
- name: 'labgate-results',
5542
- description: 'Results registry. Record and retrieve structured findings across sessions.',
5543
- active: resultsState.ready,
5544
- configured: resultsState.configured,
5545
- registered: resultsState.registered,
5546
- ready: resultsState.ready,
5547
- reason: resultsState.reason,
5548
- command: resultsEntry?.command || null,
5549
- args: Array.isArray(resultsEntry?.args) ? resultsEntry.args : null,
5550
- env: resultsEntry?.env || null,
5551
- mcpConfigPath,
5552
- serverPath: resolveServerPathFromEntry(resultsEntry, sandboxHome),
5553
- dbPath: resolveDbPathFromEntry(resultsEntry, sandboxHome),
5554
- tools: [
5555
- { name: 'list_results', title: 'List Results', description: 'List recorded results with filtering and pagination' },
5556
- { name: 'register_result', title: 'Register Result', description: 'Create a new structured result entry' },
5557
- {
5558
- name: 'register_reproducible_result',
5559
- title: 'Register Reproducible Result',
5560
- description: 'Register a result with script, inputs, requirements, and optional execution/submission',
5561
- },
5562
- { name: 'get_result', title: 'Get Result', description: 'Retrieve one result by id' },
5563
- { name: 'list_result_versions', title: 'List Result Versions', description: 'List all versions for a result lineage' },
5564
- { name: 'update_result', title: 'Update Result', description: 'Update an existing result entry' },
5565
- { name: 'delete_result', title: 'Delete Result', description: 'Delete a result entry' },
5566
- ],
5567
- },
5568
6913
  ];
5569
6914
  return {
5570
6915
  mcpConfigPath,
@@ -7652,6 +8997,20 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7652
8997
  return;
7653
8998
  }
7654
8999
  }
9000
+ if (pathname.startsWith('/api/dataset')) {
9001
+ const config = (0, config_js_1.loadConfig)();
9002
+ if (!isPluginEnabledInConfig(config, 'datasets')) {
9003
+ json(res, { ok: false, error: 'Datasets plugin is disabled.' }, 403);
9004
+ return;
9005
+ }
9006
+ }
9007
+ if (pathname === '/api/results' || pathname.startsWith('/api/results/')) {
9008
+ const config = (0, config_js_1.loadConfig)();
9009
+ if (!isPluginEnabledInConfig(config, 'results')) {
9010
+ json(res, { ok: false, error: 'Results plugin is disabled.' }, 403);
9011
+ return;
9012
+ }
9013
+ }
7655
9014
  if (pathname === '/' && method === 'GET') {
7656
9015
  serveHTML(res);
7657
9016
  }
@@ -7743,6 +9102,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7743
9102
  else if (pathname === '/api/dataset-stats' && method === 'GET') {
7744
9103
  handleGetDatasetStats(req, res);
7745
9104
  }
9105
+ else if (pathname === '/api/dataset-preview' && method === 'GET') {
9106
+ handleGetDatasetPreview(reqUrl, res);
9107
+ }
7746
9108
  else if (pathname === '/api/dataset-scan' && method === 'POST') {
7747
9109
  await handlePostDatasetScan(req, res);
7748
9110
  }
@@ -7767,6 +9129,15 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7767
9129
  else if (pathname === '/api/claude/auth' && method === 'GET') {
7768
9130
  handleGetClaudeAuthStatus(req, res);
7769
9131
  }
9132
+ else if (pathname === '/api/claude/auth/login/start' && method === 'POST') {
9133
+ await handlePostClaudeAuthLoginStart(req, res);
9134
+ }
9135
+ else if (pathname === '/api/claude/auth/login/code' && method === 'POST') {
9136
+ await handlePostClaudeAuthLoginCode(req, res);
9137
+ }
9138
+ else if (pathname === '/api/claude/oauth-url' && method === 'GET') {
9139
+ handleGetClaudeOauthUrl(reqUrl, res);
9140
+ }
7770
9141
  else if (pathname === '/api/results' && method === 'GET') {
7771
9142
  handleGetResults(reqUrl, res);
7772
9143
  }
@@ -7788,6 +9159,15 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7788
9159
  else if (pathname === '/api/terminal/history' && method === 'GET') {
7789
9160
  await handleGetWebTerminalHistory(reqUrl, res);
7790
9161
  }
9162
+ else if (pathname === '/api/terminal/bookmarks' && method === 'GET') {
9163
+ await handleGetWebTerminalBookmarks(reqUrl, res);
9164
+ }
9165
+ else if (pathname === '/api/terminal/bookmarks' && method === 'POST') {
9166
+ await handlePostWebTerminalBookmarks(req, res);
9167
+ }
9168
+ else if (pathname === '/api/terminal/bookmarks' && method === 'DELETE') {
9169
+ await handleDeleteWebTerminalBookmarks(reqUrl, res);
9170
+ }
7791
9171
  else if (pathname === '/api/terminal/init' && method === 'GET') {
7792
9172
  await handleGetWebTerminalInit(reqUrl, res);
7793
9173
  }
@@ -7803,6 +9183,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7803
9183
  else if (pathname === '/api/terminal/rename' && method === 'POST') {
7804
9184
  await handlePostWebTerminalRename(req, res);
7805
9185
  }
9186
+ else if (pathname === '/api/terminal/star' && method === 'POST') {
9187
+ await handlePostWebTerminalStar(req, res);
9188
+ }
7806
9189
  else if (pathname === '/api/terminal/stop' && method === 'POST') {
7807
9190
  await handlePostWebTerminalStop(req, res);
7808
9191
  }
@@ -8015,7 +9398,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
8015
9398
  void (async () => {
8016
9399
  if (prewarmImageOnStartup) {
8017
9400
  try {
8018
- const cfg = (0, config_js_1.loadConfig)();
9401
+ const cfg = (0, config_js_1.loadEffectiveConfig)().config;
8019
9402
  const runtimeReady = await prepareRuntimeForWebTerminal(cfg.runtime);
8020
9403
  if (!runtimeReady.ok) {
8021
9404
  const firstLine = String(runtimeReady.error || 'Container runtime unavailable.')
@@ -8048,10 +9431,10 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
8048
9431
  }
8049
9432
  }
8050
9433
  else {
8051
- imageExists = (0, fs_1.existsSync)((0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image)));
9434
+ imageExists = (0, container_js_1.isUsableApptainerSif)('apptainer', (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image)));
8052
9435
  }
8053
9436
  if (!imageExists) {
8054
- log.step(`No cached image found for ${image}. Preparing it before opening UI...`);
9437
+ log.step(`No usable cached image found for ${image}. Preparing it before opening UI...`);
8055
9438
  await ensureWebTerminalImageReady(runtime, image);
8056
9439
  log.success(`Prepared image ${image}.`);
8057
9440
  }