labgate 0.5.40 → 0.5.42

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 (44) hide show
  1. package/README.md +132 -265
  2. package/dist/cli.js +9 -33
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/config.d.ts +18 -3
  5. package/dist/lib/config.js +151 -80
  6. package/dist/lib/config.js.map +1 -1
  7. package/dist/lib/container.d.ts +11 -9
  8. package/dist/lib/container.js +753 -302
  9. package/dist/lib/container.js.map +1 -1
  10. package/dist/lib/dataset-mcp.js +2 -9
  11. package/dist/lib/dataset-mcp.js.map +1 -1
  12. package/dist/lib/display-mcp.d.ts +2 -2
  13. package/dist/lib/display-mcp.js +17 -38
  14. package/dist/lib/display-mcp.js.map +1 -1
  15. package/dist/lib/doctor.js +8 -0
  16. package/dist/lib/doctor.js.map +1 -1
  17. package/dist/lib/explorer-claude.js +36 -1
  18. package/dist/lib/explorer-claude.js.map +1 -1
  19. package/dist/lib/explorer-eval.js +3 -2
  20. package/dist/lib/explorer-eval.js.map +1 -1
  21. package/dist/lib/init.js +14 -18
  22. package/dist/lib/init.js.map +1 -1
  23. package/dist/lib/slurm-cli-passthrough.d.ts +12 -2
  24. package/dist/lib/slurm-cli-passthrough.js +401 -143
  25. package/dist/lib/slurm-cli-passthrough.js.map +1 -1
  26. package/dist/lib/startup-stage-lock.d.ts +21 -0
  27. package/dist/lib/startup-stage-lock.js +196 -0
  28. package/dist/lib/startup-stage-lock.js.map +1 -0
  29. package/dist/lib/ui.d.ts +40 -0
  30. package/dist/lib/ui.html +4953 -3366
  31. package/dist/lib/ui.js +1749 -295
  32. package/dist/lib/ui.js.map +1 -1
  33. package/dist/lib/web-terminal-startup-readiness.d.ts +8 -0
  34. package/dist/lib/web-terminal-startup-readiness.js +29 -0
  35. package/dist/lib/web-terminal-startup-readiness.js.map +1 -0
  36. package/dist/lib/web-terminal.d.ts +51 -0
  37. package/dist/lib/web-terminal.js +171 -1
  38. package/dist/lib/web-terminal.js.map +1 -1
  39. package/dist/mcp-bundles/dataset-mcp.bundle.mjs +125 -74
  40. package/dist/mcp-bundles/display-mcp.bundle.mjs +22 -30
  41. package/dist/mcp-bundles/explorer-mcp.bundle.mjs +211 -106
  42. package/dist/mcp-bundles/results-mcp.bundle.mjs +22 -24
  43. package/dist/mcp-bundles/slurm-mcp.bundle.mjs +6 -8
  44. package/package.json +1 -1
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,6 +57,7 @@ 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");
@@ -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();
@@ -647,20 +678,47 @@ async function ensureWebTerminalImageReady(runtime, image, onProgress) {
647
678
  const imagesDir = (0, config_js_1.getImagesDir)();
648
679
  const sifPath = (0, path_1.join)(imagesDir, (0, container_js_1.imageToSifName)(image));
649
680
  const pullLockPath = `${sifPath}.pull.lock`;
650
- if ((0, fs_1.existsSync)(sifPath) && !(0, fs_1.existsSync)(pullLockPath))
681
+ if ((0, container_js_1.isUsableApptainerSif)('apptainer', sifPath) && !(0, fs_1.existsSync)(pullLockPath))
651
682
  return;
652
683
  (0, fs_1.mkdirSync)(imagesDir, { recursive: true });
653
684
  await withWebTerminalImagePullLock(`apptainer:${image}`, async () => {
654
- if ((0, fs_1.existsSync)(sifPath) && !(0, fs_1.existsSync)(pullLockPath))
685
+ if ((0, container_js_1.isUsableApptainerSif)('apptainer', sifPath) && !(0, fs_1.existsSync)(pullLockPath))
655
686
  return;
656
687
  await (0, image_pull_lock_js_1.withImagePullFileLock)(pullLockPath, image, async () => {
657
- if ((0, fs_1.existsSync)(sifPath))
688
+ if ((0, container_js_1.isUsableApptainerSif)('apptainer', sifPath))
658
689
  return;
690
+ if ((0, fs_1.existsSync)(sifPath)) {
691
+ const message = `Cached image for ${image} failed validation. Re-pulling...`;
692
+ onProgress?.('image_pull', message);
693
+ if (!onProgress)
694
+ log.warn(message);
695
+ try {
696
+ (0, fs_1.unlinkSync)(sifPath);
697
+ }
698
+ catch {
699
+ // Best effort; the pull below will surface remaining problems.
700
+ }
701
+ }
702
+ const tempSifPath = `${sifPath}.tmp-${process.pid}-${(0, crypto_1.randomBytes)(6).toString('hex')}`;
659
703
  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
- });
704
+ try {
705
+ await execFileAsync('apptainer', ['pull', tempSifPath, `docker://${image}`], {
706
+ timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
707
+ maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
708
+ });
709
+ if (!(0, container_js_1.isUsableApptainerSif)('apptainer', tempSifPath)) {
710
+ throw new Error(`Pulled SIF failed validation: ${tempSifPath}`);
711
+ }
712
+ (0, fs_1.renameSync)(tempSifPath, sifPath);
713
+ }
714
+ finally {
715
+ try {
716
+ (0, fs_1.unlinkSync)(tempSifPath);
717
+ }
718
+ catch {
719
+ // Best effort cleanup for failed pulls.
720
+ }
721
+ }
664
722
  }, {
665
723
  onWait: () => {
666
724
  const message = `Waiting for another session to finish pulling ${image}...`;
@@ -825,15 +883,38 @@ function toRuntimeUnavailableResult(runtimeReady) {
825
883
  };
826
884
  }
827
885
  async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
886
+ const startupStartedAt = Date.now();
887
+ const startupTimings = [];
888
+ const recordStartupTiming = (label, startedAt) => {
889
+ startupTimings.push([label, Math.max(0, Date.now() - startedAt)]);
890
+ };
891
+ const formatStartupTiming = (ms) => (ms < 1000 ? `${Math.round(ms)}ms` : ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`);
892
+ const flushStartupTimingLog = (readiness, outcome) => {
893
+ const parts = startupTimings
894
+ .filter(([, ms]) => Number.isFinite(ms))
895
+ .map(([label, ms]) => `${label}=${formatStartupTiming(ms)}`);
896
+ if (readiness) {
897
+ parts.push(`readiness_source=${readiness.source}`);
898
+ parts.push(`readiness_wait=${formatStartupTiming(readiness.elapsedMs)}`);
899
+ parts.push(`capture_checks=${readiness.captureChecks}`);
900
+ }
901
+ parts.push(`total=${formatStartupTiming(Date.now() - startupStartedAt)}`);
902
+ log.step(`[labgate] web terminal startup (${agent}, ${outcome}): ${parts.join(', ')}`);
903
+ };
828
904
  const onProgress = opts.onProgress;
829
- const config = (0, config_js_1.loadConfig)();
905
+ const effective = (0, config_js_1.loadEffectiveConfig)();
906
+ const config = effective.config;
830
907
  onProgress?.('runtime_setup', 'Checking container runtime...');
908
+ const runtimeStartedAt = Date.now();
831
909
  const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
910
+ recordStartupTiming('runtime_setup', runtimeStartedAt);
832
911
  if (!runtimeReady.ok) {
912
+ flushStartupTimingLog(null, 'failed');
833
913
  return toRuntimeUnavailableResult(runtimeReady);
834
914
  }
835
915
  const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
836
916
  if (!runtimeCheck.ok || !runtimeCheck.runtime) {
917
+ flushStartupTimingLog(null, 'failed');
837
918
  return {
838
919
  ok: false,
839
920
  status: runtimeReady.initialized ? 502 : 503,
@@ -849,6 +930,7 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
849
930
  // Preflight tmux before any slow image/agent preparation to avoid unnecessary side effects.
850
931
  const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
851
932
  if (!tmuxAvailable.ok) {
933
+ flushStartupTimingLog(null, 'failed');
852
934
  return {
853
935
  ok: false,
854
936
  status: 500,
@@ -856,10 +938,14 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
856
938
  };
857
939
  }
858
940
  if (opts.prewarmImage) {
941
+ const imagePrepareStartedAt = Date.now();
859
942
  try {
860
943
  await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, onProgress);
944
+ recordStartupTiming('image_prepare', imagePrepareStartedAt);
861
945
  }
862
946
  catch (err) {
947
+ recordStartupTiming('image_prepare', imagePrepareStartedAt);
948
+ flushStartupTimingLog(null, 'failed');
863
949
  const detail = commandErrorDetail(err);
864
950
  return {
865
951
  ok: false,
@@ -876,10 +962,14 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
876
962
  }
877
963
  }
878
964
  if (opts.prewarmAgent) {
965
+ const agentPrepareStartedAt = Date.now();
879
966
  try {
880
967
  await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, agent, resolvedWorkdir, config.network.mode, onProgress);
968
+ recordStartupTiming('agent_prepare', agentPrepareStartedAt);
881
969
  }
882
970
  catch (err) {
971
+ recordStartupTiming('agent_prepare', agentPrepareStartedAt);
972
+ flushStartupTimingLog(null, 'failed');
883
973
  const detail = commandErrorDetail(err);
884
974
  return {
885
975
  ok: false,
@@ -907,15 +997,19 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
907
997
  });
908
998
  (0, web_terminal_js_1.writeWebTerminalRecord)(record);
909
999
  onProgress?.('session_start', `Starting ${agent} terminal session...`);
1000
+ const tmuxSessionStartedAt = Date.now();
910
1001
  try {
911
1002
  await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint, {
912
1003
  permissionMode: opts.permissionMode || 'default',
913
1004
  });
914
1005
  (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
1006
+ recordStartupTiming('tmux_session_start', tmuxSessionStartedAt);
915
1007
  }
916
1008
  catch (err) {
1009
+ recordStartupTiming('tmux_session_start', tmuxSessionStartedAt);
917
1010
  const message = err?.message ?? String(err);
918
1011
  (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
1012
+ flushStartupTimingLog(null, 'failed');
919
1013
  return {
920
1014
  ok: false,
921
1015
  status: 500,
@@ -934,6 +1028,7 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
934
1028
  exitCode: 1,
935
1029
  error: 'node-pty bridge unavailable',
936
1030
  });
1031
+ flushStartupTimingLog(null, 'failed');
937
1032
  return {
938
1033
  ok: false,
939
1034
  status: 500,
@@ -943,7 +1038,27 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
943
1038
  },
944
1039
  };
945
1040
  }
946
- await waitForWebTerminalStartupSummary(record, bridge, onProgress);
1041
+ const readinessWaitStartedAt = Date.now();
1042
+ const readiness = await waitForWebTerminalStartupSummary(record, bridge, onProgress);
1043
+ recordStartupTiming('web_terminal_readiness_wait', readinessWaitStartedAt);
1044
+ if (!readiness.ready && readiness.source !== 'timeout') {
1045
+ const message = readiness.source === 'bridge-detached'
1046
+ ? 'Terminal bridge detached before startup completed.'
1047
+ : readiness.source === 'tmux-exited'
1048
+ ? 'tmux session exited before startup completed.'
1049
+ : 'Terminal startup did not complete.';
1050
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
1051
+ flushStartupTimingLog(readiness, 'failed');
1052
+ return {
1053
+ ok: false,
1054
+ status: 500,
1055
+ body: { ok: false, error: message },
1056
+ };
1057
+ }
1058
+ flushStartupTimingLog(readiness, 'ready');
1059
+ if (!readiness.ready) {
1060
+ onProgress?.('session_start', 'Terminal started. LabGate startup summary timed out; continuing with live session output.');
1061
+ }
947
1062
  return {
948
1063
  ok: true,
949
1064
  session: serializeWebTerminalSession(record),
@@ -1243,6 +1358,7 @@ function serializeWebTerminalSession(record) {
1243
1358
  return {
1244
1359
  id: record.id,
1245
1360
  name: record.name || '',
1361
+ starred: record.starred === true,
1246
1362
  agent: record.agent,
1247
1363
  runtime: record.runtime || '',
1248
1364
  workdir: record.workdir,
@@ -1368,6 +1484,49 @@ function collectClaudeTextFromContent(content) {
1368
1484
  }
1369
1485
  return text;
1370
1486
  }
1487
+ function collectToolResultText(value) {
1488
+ if (typeof value === 'string')
1489
+ return value;
1490
+ if (Array.isArray(value)) {
1491
+ let text = '';
1492
+ for (const item of value) {
1493
+ text += collectToolResultText(item);
1494
+ }
1495
+ return text;
1496
+ }
1497
+ if (!value || typeof value !== 'object')
1498
+ return '';
1499
+ const record = value;
1500
+ if (record.type === 'text' && typeof record.text === 'string')
1501
+ return record.text;
1502
+ if (typeof record.content === 'string')
1503
+ return record.content;
1504
+ if (record.content !== undefined)
1505
+ return collectToolResultText(record.content);
1506
+ if (typeof record.text === 'string')
1507
+ return record.text;
1508
+ return '';
1509
+ }
1510
+ function summarizeToolResultDetail(raw, max = 420) {
1511
+ const compact = String(raw || '').replace(/\r/g, '').trim();
1512
+ if (!compact)
1513
+ return '';
1514
+ if (compact.length <= max)
1515
+ return compact;
1516
+ return `${compact.slice(0, Math.max(1, max - 3))}...`;
1517
+ }
1518
+ function extractToolResultDetailFromBlock(block) {
1519
+ const fromContent = summarizeToolResultDetail(collectToolResultText(block.content));
1520
+ if (fromContent)
1521
+ return fromContent;
1522
+ const fromError = summarizeToolResultDetail(readRecordString(block, 'error'));
1523
+ if (fromError)
1524
+ return fromError;
1525
+ const fromMessage = summarizeToolResultDetail(readRecordString(block, 'message'));
1526
+ if (fromMessage)
1527
+ return fromMessage;
1528
+ return '';
1529
+ }
1371
1530
  function extractClaudeStreamSessionId(event) {
1372
1531
  const direct = readRecordString(event, 'session_id').trim();
1373
1532
  if (direct)
@@ -1402,87 +1561,288 @@ function isClaudeAuthenticationFailure(event, assistantSnapshot, stderrText) {
1402
1561
  const authRe = /oauth token has expired|authentication_error|failed to authenticate|api error:\s*401/i;
1403
1562
  return authRe.test(packed) || authRe.test(message);
1404
1563
  }
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
1564
+ function isDisplayWidgetToolName(rawName) {
1565
+ const normalized = normalizeToolName(rawName)
1566
+ .replace(/[\s.-]+/g, '_');
1567
+ if (!normalized)
1568
+ return false;
1569
+ return normalized === 'display_widget'
1570
+ || normalized.endsWith('__display_widget')
1571
+ || normalized.endsWith('_display_widget');
1572
+ }
1573
+ function ensureDisplayDbFileReady() {
1410
1574
  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
- });
1575
+ if ((0, fs_1.existsSync)(displayDbPath))
1576
+ return;
1577
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(displayDbPath));
1578
+ (0, fs_1.writeFileSync)(displayDbPath, JSON.stringify({ version: 1, events: [] }, null, 2) + '\n', {
1579
+ encoding: 'utf-8',
1580
+ mode: config_js_1.PRIVATE_FILE_MODE,
1581
+ });
1582
+ }
1583
+ function buildClaudeHeadlessAddDirArgs(config) {
1584
+ const dirs = new Set();
1585
+ let hasExtraMounts = false;
1586
+ let hasDatasets = false;
1587
+ for (const mount of config.filesystem.extra_paths || []) {
1588
+ if (!mount || typeof mount.path !== 'string')
1589
+ continue;
1590
+ const resolved = mount.path.replace(/^~/, (0, os_1.homedir)());
1591
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1592
+ hasExtraMounts = true;
1593
+ dirs.add(target);
1594
+ }
1595
+ for (const ds of config.datasets || []) {
1596
+ if (!ds || typeof ds.name !== 'string')
1597
+ continue;
1598
+ const name = ds.name.trim();
1599
+ if (!name)
1600
+ continue;
1601
+ hasDatasets = true;
1602
+ dirs.add(`/datasets/${name}`);
1417
1603
  }
1604
+ if (hasExtraMounts)
1605
+ dirs.add('/mnt');
1606
+ if (hasDatasets)
1607
+ dirs.add('/datasets');
1608
+ return Array.from(dirs).flatMap((dir) => ['--add-dir', dir]);
1609
+ }
1610
+ function buildClaudeHeadlessAllowedToolsArgs() {
1611
+ // Keep approvals enabled globally, but pre-allow the tools needed for
1612
+ // headless discovery + web lookups in mounted workspaces.
1613
+ return ['--allowed-tools', 'Glob,Read,Grep,WebFetch'];
1614
+ }
1615
+ function buildClaudeHeadlessInvocationArgs(config, prompt, resumeSessionId, runWithAllowedPermissions) {
1616
+ const resume = resumeSessionId.trim();
1418
1617
  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
1618
  '/home/sandbox/.npm-global/bin/claude',
1442
1619
  '-p',
1443
1620
  '--verbose',
1444
1621
  '--output-format',
1445
1622
  'stream-json',
1446
1623
  '--include-partial-messages',
1624
+ ...buildClaudeHeadlessAddDirArgs(config),
1625
+ ...buildClaudeHeadlessAllowedToolsArgs(),
1447
1626
  ...(runWithAllowedPermissions ? ['--dangerously-skip-permissions'] : []),
1448
1627
  ...(resume ? ['--resume', resume] : []),
1449
1628
  prompt,
1450
1629
  ];
1451
1630
  }
1631
+ function buildClaudeHeadlessRuntimeCommand(runtime, config, workdir, prompt, resumeSessionId, runWithAllowedPermissions) {
1632
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
1633
+ // Ensure display.json exists before bind-mounting it
1634
+ ensureDisplayDbFileReady();
1635
+ const displayDbPath = (0, config_js_1.getDisplayDbPath)();
1636
+ const claudeArgs = buildClaudeHeadlessInvocationArgs(config, prompt, resumeSessionId, runWithAllowedPermissions);
1637
+ if (runtime === 'podman') {
1638
+ return {
1639
+ command: 'podman',
1640
+ args: [
1641
+ 'run',
1642
+ '--rm',
1643
+ '--workdir', '/work',
1644
+ '--volume', `${sandboxHome}:/home/sandbox`,
1645
+ '--volume', `${workdir}:/work`,
1646
+ ...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
1647
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1648
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1649
+ const volSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
1650
+ return ['--volume', volSpec];
1651
+ }),
1652
+ ...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
1653
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1654
+ const volSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
1655
+ return ['--volume', volSpec];
1656
+ }),
1657
+ ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--volume', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1658
+ '--volume', `${displayDbPath}:/labgate-config/display.json`,
1659
+ ...getPodmanPrewarmNetworkArgs(config.network.mode),
1660
+ '--env', 'HOME=/home/sandbox',
1661
+ '--env', 'ANTHROPIC_API_KEY=',
1662
+ config.image,
1663
+ ...claudeArgs,
1664
+ ],
1665
+ };
1666
+ }
1667
+ const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
1668
+ return {
1669
+ command: 'apptainer',
1670
+ args: [
1671
+ 'exec',
1672
+ '--containall',
1673
+ '--cleanenv',
1674
+ '--home', `${sandboxHome}:/home/sandbox`,
1675
+ '--bind', `${workdir}:/work`,
1676
+ '--pwd', '/work',
1677
+ ...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
1678
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1679
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
1680
+ const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
1681
+ return ['--bind', bindSpec];
1682
+ }),
1683
+ ...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
1684
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
1685
+ const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
1686
+ return ['--bind', bindSpec];
1687
+ }),
1688
+ ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1689
+ '--bind', `${displayDbPath}:/labgate-config/display.json`,
1690
+ '--env', 'HOME=/home/sandbox',
1691
+ '--env', 'ANTHROPIC_API_KEY=',
1692
+ sifPath,
1693
+ ...claudeArgs,
1694
+ ],
1695
+ };
1696
+ }
1452
1697
  function sleep(ms) {
1453
1698
  return new Promise((resolve) => setTimeout(resolve, ms));
1454
1699
  }
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;
1700
+ async function waitForWebTerminalStartupSummary(record, bridge, onProgress, deps = {}) {
1701
+ const formatElapsedLabel = (elapsedMs) => {
1702
+ const totalSeconds = Math.max(1, Math.round(elapsedMs / 1000));
1703
+ if (totalSeconds < 60)
1704
+ return `${totalSeconds}s`;
1705
+ const minutes = Math.floor(totalSeconds / 60);
1706
+ const seconds = totalSeconds % 60;
1707
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
1708
+ };
1709
+ const describeWait = (sawLaunchSignal, sawSummary, elapsedMs) => {
1710
+ const agentLabel = record.agent === 'claude' ? 'Claude' : 'Codex';
1711
+ let message = `Waiting for ${agentLabel} startup output`;
1712
+ if (sawLaunchSignal && !sawSummary) {
1713
+ message = `${agentLabel} launched. Waiting for LabGate startup summary`;
1714
+ }
1715
+ else if (!sawLaunchSignal && sawSummary) {
1716
+ message = `LabGate startup summary detected. Waiting for ${agentLabel} launch banner`;
1717
+ }
1718
+ if (!Number.isFinite(elapsedMs) || !elapsedMs || elapsedMs <= 0)
1719
+ return `${message}...`;
1720
+ const slowHint = elapsedMs >= 30_000 ? '; initial auth/setup can be slow' : '';
1721
+ return `${message}... (${formatElapsedLabel(elapsedMs)} elapsed${slowHint})`;
1722
+ };
1723
+ const nowFn = deps.now ?? Date.now;
1724
+ const sleepFn = deps.sleep ?? sleep;
1725
+ const hasTmuxSessionFn = deps.hasTmuxSession ?? web_terminal_js_1.hasTmuxSession;
1726
+ const getTmuxBinaryFn = deps.getTmuxBinary ?? web_terminal_js_1.getTmuxBinary;
1727
+ const capturePane = deps.capturePane ?? (async (tmuxBin, sessionName) => {
1728
+ 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 });
1729
+ return String(stdout || '');
1730
+ });
1731
+ const startMs = nowFn();
1732
+ const deadline = startMs + WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS;
1467
1733
  let lastAliveCheck = 0;
1468
- onProgress?.('session_start', `Finalizing ${record.agent} startup...`);
1469
- while (Date.now() < deadline) {
1734
+ let lastCaptureCheck = 0;
1735
+ let lastProgressBucket = 0;
1736
+ let lastLiveOutputAt = startMs;
1737
+ let lastObservedBufferLength = 0;
1738
+ let tmuxBin = null;
1739
+ let sawLaunchSignal = false;
1740
+ let sawSummary = false;
1741
+ let sawDeviceAuth = false;
1742
+ let captureChecks = 0;
1743
+ const captureIntervalFor = (elapsedMs) => {
1744
+ if (sawLaunchSignal || sawSummary)
1745
+ return WEB_TERMINAL_STARTUP_CAPTURE_SLOW_CHECK_MS;
1746
+ return elapsedMs < 5_000 ? WEB_TERMINAL_STARTUP_CAPTURE_FAST_CHECK_MS : WEB_TERMINAL_STARTUP_CAPTURE_SLOW_CHECK_MS;
1747
+ };
1748
+ const observeStartupSignals = (text, source) => {
1749
+ const signals = (0, web_terminal_startup_readiness_js_1.getWebTerminalStartupSignals)(record.agent, text);
1750
+ const hadLaunchSignal = sawLaunchSignal;
1751
+ const hadSummary = sawSummary;
1752
+ sawLaunchSignal ||= signals.hasLaunchSignal;
1753
+ sawSummary ||= signals.hasSummary;
1754
+ sawDeviceAuth ||= signals.launchKind === 'device-auth';
1755
+ if (!sawLaunchSignal || !sawSummary)
1756
+ return null;
1757
+ if (sawDeviceAuth)
1758
+ return 'device-auth';
1759
+ if (signals.hasLaunchSignal && signals.hasSummary) {
1760
+ return source === 'live' ? 'live-bridge' : 'tmux-capture';
1761
+ }
1762
+ if (hadLaunchSignal !== sawLaunchSignal || hadSummary !== sawSummary) {
1763
+ return 'latched-launch+summary';
1764
+ }
1765
+ return source === 'live' ? 'live-bridge' : 'tmux-capture';
1766
+ };
1767
+ onProgress?.('session_start', describeWait(false, false));
1768
+ while (nowFn() < deadline) {
1769
+ if (bridge.buffer.length !== lastObservedBufferLength) {
1770
+ lastObservedBufferLength = bridge.buffer.length;
1771
+ lastLiveOutputAt = nowFn();
1772
+ }
1470
1773
  const recent = bridge.buffer.length > WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT
1471
1774
  ? bridge.buffer.slice(-WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT)
1472
1775
  : bridge.buffer;
1473
- if (hasWebTerminalStartupSummary(recent))
1474
- return;
1475
- if (!bridge.pty)
1476
- return;
1477
- const now = Date.now();
1776
+ const liveReadySource = (0, web_terminal_startup_readiness_js_1.isWebTerminalStartupReady)(record.agent, recent)
1777
+ ? ((0, web_terminal_startup_readiness_js_1.getWebTerminalStartupSignals)(record.agent, recent).launchKind === 'device-auth' ? 'device-auth' : 'live-bridge')
1778
+ : observeStartupSignals(recent, 'live');
1779
+ if (liveReadySource) {
1780
+ return {
1781
+ ready: true,
1782
+ source: liveReadySource,
1783
+ elapsedMs: Math.max(0, nowFn() - startMs),
1784
+ captureChecks,
1785
+ };
1786
+ }
1787
+ if (!bridge.pty) {
1788
+ return {
1789
+ ready: false,
1790
+ source: 'bridge-detached',
1791
+ elapsedMs: Math.max(0, nowFn() - startMs),
1792
+ captureChecks,
1793
+ };
1794
+ }
1795
+ const now = nowFn();
1478
1796
  if (now - lastAliveCheck >= WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS) {
1479
1797
  lastAliveCheck = now;
1480
- const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
1481
- if (!alive)
1482
- return;
1798
+ const alive = await hasTmuxSessionFn(record.tmuxSession);
1799
+ if (!alive) {
1800
+ return {
1801
+ ready: false,
1802
+ source: 'tmux-exited',
1803
+ elapsedMs: Math.max(0, nowFn() - startMs),
1804
+ captureChecks,
1805
+ };
1806
+ }
1807
+ }
1808
+ const captureIntervalMs = captureIntervalFor(now - startMs);
1809
+ const recentLiveOutput = (now - lastLiveOutputAt) < WEB_TERMINAL_STARTUP_CAPTURE_SKIP_ON_LIVE_MS;
1810
+ if ((now - lastCaptureCheck) >= captureIntervalMs && !(recentLiveOutput && (sawLaunchSignal || sawSummary))) {
1811
+ lastCaptureCheck = now;
1812
+ try {
1813
+ tmuxBin ||= await getTmuxBinaryFn();
1814
+ captureChecks += 1;
1815
+ const captured = await capturePane(tmuxBin, record.tmuxSession);
1816
+ const captureReadySource = (0, web_terminal_startup_readiness_js_1.isWebTerminalStartupReady)(record.agent, captured)
1817
+ ? ((0, web_terminal_startup_readiness_js_1.getWebTerminalStartupSignals)(record.agent, captured).launchKind === 'device-auth' ? 'device-auth' : 'tmux-capture')
1818
+ : observeStartupSignals(captured, 'capture');
1819
+ if (captureReadySource) {
1820
+ return {
1821
+ ready: true,
1822
+ source: captureReadySource,
1823
+ elapsedMs: Math.max(0, nowFn() - startMs),
1824
+ captureChecks,
1825
+ };
1826
+ }
1827
+ }
1828
+ catch {
1829
+ // Best effort only. Live bridge output remains the primary signal.
1830
+ }
1483
1831
  }
1484
- await sleep(WEB_TERMINAL_STARTUP_READY_POLL_MS);
1832
+ const elapsedMs = nowFn() - startMs;
1833
+ const progressBucket = Math.floor(elapsedMs / WEB_TERMINAL_STARTUP_PROGRESS_UPDATE_MS);
1834
+ if (progressBucket > lastProgressBucket) {
1835
+ lastProgressBucket = progressBucket;
1836
+ onProgress?.('session_start', describeWait(sawLaunchSignal, sawSummary, elapsedMs));
1837
+ }
1838
+ await sleepFn(WEB_TERMINAL_STARTUP_READY_POLL_MS);
1485
1839
  }
1840
+ return {
1841
+ ready: false,
1842
+ source: 'timeout',
1843
+ elapsedMs: Math.max(0, nowFn() - startMs),
1844
+ captureChecks,
1845
+ };
1486
1846
  }
1487
1847
  function broadcastWebTerminalMessage(bridge, payload) {
1488
1848
  for (const ws of bridge.clients) {
@@ -1571,12 +1931,12 @@ async function ensureWebTerminalBridge(record) {
1571
1931
  env,
1572
1932
  };
1573
1933
  try {
1574
- ptyProcess = ptyModule.spawn(tmuxBin, ['attach-session', '-t', record.tmuxSession], spawnOpts);
1934
+ ptyProcess = ptyModule.spawn(tmuxBin, ['attach-session', '-d', '-t', record.tmuxSession], spawnOpts);
1575
1935
  }
1576
1936
  catch (err) {
1577
1937
  const shell = (process.env.SHELL || '/bin/bash').trim() || '/bin/bash';
1578
1938
  const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
1579
- const launch = `${quote(tmuxBin)} attach-session -t ${quote(record.tmuxSession)}`;
1939
+ const launch = `${quote(tmuxBin)} attach-session -d -t ${quote(record.tmuxSession)}`;
1580
1940
  try {
1581
1941
  ptyProcess = ptyModule.spawn(shell, ['-lc', launch], spawnOpts);
1582
1942
  }
@@ -1682,7 +2042,8 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1682
2042
  }
1683
2043
  send({ type: 'status', stage: 'runtime_setup', message: 'Checking container runtime...' });
1684
2044
  const config = (0, config_js_1.loadConfig)();
1685
- const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
2045
+ const runtimePreference = record.runtime || config.runtime;
2046
+ const runtimeReady = await prepareRuntimeForWebTerminal(runtimePreference);
1686
2047
  if (!runtimeReady.ok) {
1687
2048
  send({
1688
2049
  type: 'error',
@@ -1691,7 +2052,7 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1691
2052
  });
1692
2053
  return () => { };
1693
2054
  }
1694
- const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
2055
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(runtimePreference);
1695
2056
  if (!runtimeCheck.ok || !runtimeCheck.runtime) {
1696
2057
  send({
1697
2058
  type: 'error',
@@ -1700,16 +2061,17 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1700
2061
  });
1701
2062
  return () => { };
1702
2063
  }
1703
- if (runtimeCheck.runtime !== 'apptainer') {
2064
+ const runtime = runtimeCheck.runtime;
2065
+ if (runtime !== 'apptainer' && runtime !== 'podman') {
1704
2066
  send({
1705
2067
  type: 'error',
1706
2068
  code: 'runtime_unsupported',
1707
- error: `Headless Claude chat currently supports Apptainer only (detected: ${runtimeCheck.runtime}).`,
2069
+ error: `Headless Claude chat requires an Apptainer or Podman runtime (detected: ${runtime}).`,
1708
2070
  });
1709
2071
  return () => { };
1710
2072
  }
1711
2073
  try {
1712
- await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, (stage, message) => {
2074
+ await ensureWebTerminalImageReady(runtime, config.image, (stage, message) => {
1713
2075
  send({ type: 'status', stage, message });
1714
2076
  });
1715
2077
  }
@@ -1722,7 +2084,7 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1722
2084
  return () => { };
1723
2085
  }
1724
2086
  try {
1725
- await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, 'claude', record.workdir, config.network.mode, (stage, message) => send({ type: 'status', stage, message }));
2087
+ await ensureWebTerminalAgentReady(runtime, config.image, 'claude', record.workdir, config.network.mode, (stage, message) => send({ type: 'status', stage, message }));
1726
2088
  }
1727
2089
  catch (err) {
1728
2090
  send({
@@ -1732,8 +2094,8 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1732
2094
  });
1733
2095
  return () => { };
1734
2096
  }
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, {
2097
+ const command = buildClaudeHeadlessRuntimeCommand(runtime, config, record.workdir, trimmedPrompt, resumeSessionId, (0, config_js_1.shouldClaudeHeadlessRunWithAllowedPermissions)(config));
2098
+ const child = (0, child_process_1.spawn)(command.command, command.args, {
1737
2099
  cwd: record.workdir,
1738
2100
  env: process.env,
1739
2101
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1744,6 +2106,7 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1744
2106
  let emittedAssistantText = '';
1745
2107
  let doneSent = false;
1746
2108
  let syntheticToolUseSeq = 0;
2109
+ let authRequiredSent = false;
1747
2110
  const sendDone = (exitCode) => {
1748
2111
  if (doneSent)
1749
2112
  return;
@@ -1806,12 +2169,12 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1806
2169
  const detail = extractToolDetailFromToolUseBlock(toolBlock);
1807
2170
  const toolUseId = normalizeToolUseId(toolBlock.id) || `tool-${Date.now().toString(36)}-${(++syntheticToolUseSeq).toString(36)}`;
1808
2171
  // 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') {
2172
+ if (isDisplayWidgetToolName(toolName)) {
2173
+ const input = parseToolInput(toolBlock.input ?? toolBlock.arguments ?? toolBlock.params);
2174
+ if (typeof input.widget === 'string' && input.widget.trim()) {
1812
2175
  send({
1813
2176
  type: 'rich_content',
1814
- widget: String(input.widget),
2177
+ widget: String(input.widget).trim(),
1815
2178
  title: input.title ? String(input.title) : undefined,
1816
2179
  data: (input.data && typeof input.data === 'object') ? input.data : {},
1817
2180
  id: toolUseId,
@@ -1832,19 +2195,22 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1832
2195
  if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_result') {
1833
2196
  const resultBlock = block;
1834
2197
  const toolUseId = normalizeToolUseId(resultBlock.tool_use_id);
2198
+ const detail = extractToolResultDetailFromBlock(resultBlock);
1835
2199
  send({
1836
2200
  type: 'tool_result',
1837
2201
  tool_use_id: toolUseId || undefined,
1838
2202
  is_error: !!resultBlock.is_error,
2203
+ detail: detail || undefined,
1839
2204
  });
1840
2205
  }
1841
2206
  }
1842
2207
  }
1843
2208
  }
1844
- if (isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
2209
+ if (!authRequiredSent && isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
2210
+ authRequiredSent = true;
1845
2211
  send({
1846
2212
  type: 'auth_required',
1847
- error: 'Claude authentication is required. Run /login in raw terminal mode to refresh session.',
2213
+ error: 'Claude authentication is required. Type /login in chat (or run `claude auth login` in raw mode) to refresh session.',
1848
2214
  });
1849
2215
  }
1850
2216
  }
@@ -2000,21 +2366,7 @@ async function handlePostConfig(req, res) {
2000
2366
  }
2001
2367
  }
2002
2368
  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
- }
2369
+ const obj = (0, config_js_1.readRawConfigFile)(configPath);
2018
2370
  // Merge incoming config
2019
2371
  obj.runtime = incoming.runtime;
2020
2372
  obj.image = incoming.image;
@@ -2030,9 +2382,7 @@ async function handlePostConfig(req, res) {
2030
2382
  obj.headless = incoming.headless;
2031
2383
  if (incoming.plugins)
2032
2384
  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);
2385
+ (0, config_js_1.writeRawConfigFile)(obj, configPath);
2036
2386
  json(res, { ok: true });
2037
2387
  }
2038
2388
  catch (err) {
@@ -2067,20 +2417,13 @@ async function handlePostPlugins(req, res) {
2067
2417
  }
2068
2418
  // Read existing config file
2069
2419
  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
- }
2420
+ let obj;
2421
+ try {
2422
+ obj = (0, config_js_1.readRawConfigFile)(configPath);
2423
+ }
2424
+ catch (err) {
2425
+ json(res, { ok: false, errors: [`Could not parse existing config file: ${err.message ?? String(err)}`] }, 400);
2426
+ return;
2084
2427
  }
2085
2428
  // Merge plugin state
2086
2429
  const rawPlugins = (obj.plugins ?? {});
@@ -2096,9 +2439,7 @@ async function handlePostPlugins(req, res) {
2096
2439
  }
2097
2440
  plugins[pluginId] = enabled;
2098
2441
  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);
2442
+ (0, config_js_1.writeRawConfigFile)(obj, configPath);
2102
2443
  json(res, { ok: true, plugins });
2103
2444
  }
2104
2445
  catch (err) {
@@ -2832,6 +3173,182 @@ function collectWebsiteUrls(value, accessedUrls, ts, keyHint = '') {
2832
3173
  }
2833
3174
  }
2834
3175
  }
3176
+ function normalizeToolName(rawName) {
3177
+ return String(rawName || '').trim().toLowerCase();
3178
+ }
3179
+ function parseToolInput(rawInput) {
3180
+ if (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) {
3181
+ return rawInput;
3182
+ }
3183
+ if (typeof rawInput === 'string') {
3184
+ try {
3185
+ const parsed = JSON.parse(rawInput);
3186
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
3187
+ return parsed;
3188
+ }
3189
+ }
3190
+ catch {
3191
+ // Some tools pass non-JSON strings; ignore.
3192
+ }
3193
+ }
3194
+ return {};
3195
+ }
3196
+ function classifyToolAction(toolName) {
3197
+ if (toolName === 'edit' ||
3198
+ toolName === 'multiedit' ||
3199
+ toolName === 'apply_patch' ||
3200
+ toolName.endsWith('.apply_patch') ||
3201
+ toolName === 'mcp__obsidian__patch_note')
3202
+ return 'edit';
3203
+ if (toolName === 'write' ||
3204
+ toolName === 'notebookedit' ||
3205
+ toolName === 'mcp__obsidian__write_note' ||
3206
+ toolName === 'mcp__obsidian__update_frontmatter' ||
3207
+ toolName === 'mcp__obsidian__move_note' ||
3208
+ toolName === 'mcp__obsidian__delete_note' ||
3209
+ toolName === 'mcp__obsidian__manage_tags')
3210
+ return 'write';
3211
+ if (toolName === 'read' ||
3212
+ toolName === 'glob' ||
3213
+ toolName === 'grep' ||
3214
+ toolName === 'mcp__obsidian__read_note' ||
3215
+ toolName === 'mcp__obsidian__read_multiple_notes' ||
3216
+ toolName === 'mcp__obsidian__get_frontmatter' ||
3217
+ toolName === 'mcp__obsidian__get_notes_info' ||
3218
+ toolName === 'mcp__obsidian__get_vault_stats' ||
3219
+ toolName === 'mcp__obsidian__list_directory' ||
3220
+ toolName === 'mcp__obsidian__search_notes')
3221
+ return 'read';
3222
+ return 'unknown';
3223
+ }
3224
+ function normalizeTrackedPath(rawPath) {
3225
+ const token = sanitizeTokenEdge(rawPath || '');
3226
+ if (!token)
3227
+ return '';
3228
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token))
3229
+ return '';
3230
+ if (token.startsWith('/'))
3231
+ return token;
3232
+ if (/^[A-Za-z]:[\\/]/.test(token))
3233
+ return token;
3234
+ if (token === '.' || token === '..')
3235
+ return '';
3236
+ const relative = token.replace(/^\.\/+/, '');
3237
+ return relative ? `/work/${relative}` : '';
3238
+ }
3239
+ function rememberToolPathValue(accessedFiles, value, ts, action) {
3240
+ if (typeof value === 'string') {
3241
+ const normalized = normalizeTrackedPath(value);
3242
+ if (normalized)
3243
+ rememberFileEntry(accessedFiles, normalized, ts, action);
3244
+ return;
3245
+ }
3246
+ if (Array.isArray(value)) {
3247
+ for (const item of value) {
3248
+ if (typeof item === 'string') {
3249
+ const normalized = normalizeTrackedPath(item);
3250
+ if (normalized)
3251
+ rememberFileEntry(accessedFiles, normalized, ts, action);
3252
+ }
3253
+ }
3254
+ }
3255
+ }
3256
+ function classifyCommandVerbAction(command) {
3257
+ const verb = String(command || '').trim().toLowerCase();
3258
+ if (!verb)
3259
+ return 'unknown';
3260
+ if ([
3261
+ 'cat', 'head', 'tail', 'less', 'more', 'wc', 'diff', 'grep', 'rg', 'awk',
3262
+ 'find', 'ls', 'stat', 'realpath', 'readlink',
3263
+ ].includes(verb))
3264
+ return 'read';
3265
+ if (['vi', 'vim', 'nano', 'code', 'sed', 'perl'].includes(verb))
3266
+ return 'edit';
3267
+ if (['write', 'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'mkdir', 'rmdir', 'truncate', 'tee'].includes(verb))
3268
+ return 'write';
3269
+ return 'unknown';
3270
+ }
3271
+ function collectFileAccessFromCommand(commandRaw, ts, accessedFiles, accessedUrls) {
3272
+ const cmd = typeof commandRaw === 'string' ? commandRaw : '';
3273
+ if (!cmd.trim())
3274
+ return;
3275
+ for (const url of extractWebsiteUrlsFromText(cmd)) {
3276
+ rememberTimestampedEntry(accessedUrls, url, ts);
3277
+ }
3278
+ const segments = cmd.split(/(?:\|\||&&|[|;])/).map((part) => part.trim()).filter(Boolean);
3279
+ for (const segment of segments) {
3280
+ const words = segment.split(/\s+/).filter(Boolean);
3281
+ if (words.length === 0)
3282
+ continue;
3283
+ const verb = words[0].replace(/^[^A-Za-z0-9._-]+/, '').replace(/[^\w.-]+$/, '');
3284
+ const action = classifyCommandVerbAction(verb);
3285
+ const absMatches = segment.match(/\/work\/\S+/g);
3286
+ if (absMatches) {
3287
+ for (const p of absMatches) {
3288
+ const clean = p.replace(/['"`;,)}\]]+$/, '');
3289
+ rememberFileEntry(accessedFiles, clean, ts, action);
3290
+ }
3291
+ }
3292
+ for (let idx = 1; idx < words.length; idx += 1) {
3293
+ const token = sanitizeTokenEdge(words[idx]);
3294
+ if (!token || token.startsWith('-') || token === '.' || token === '..')
3295
+ continue;
3296
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token))
3297
+ continue;
3298
+ if (token.startsWith('/')) {
3299
+ if (token.startsWith('/work/'))
3300
+ rememberFileEntry(accessedFiles, token, ts, action);
3301
+ continue;
3302
+ }
3303
+ if (token.includes('=') && !token.includes('/') && !token.includes('.'))
3304
+ continue;
3305
+ if (token.includes('/') || token.includes('.')) {
3306
+ const relative = token.replace(/^\.\/+/, '');
3307
+ if (relative)
3308
+ rememberFileEntry(accessedFiles, `/work/${relative}`, ts, action);
3309
+ }
3310
+ }
3311
+ }
3312
+ }
3313
+ function collectFileAccessFromPatch(patchRaw, ts, accessedFiles) {
3314
+ const patch = typeof patchRaw === 'string' ? patchRaw : '';
3315
+ if (!patch)
3316
+ return;
3317
+ const fileLineRe = /\*\*\* (?:Add|Update|Delete) File:\s+([^\n\r]+)/g;
3318
+ let match;
3319
+ while ((match = fileLineRe.exec(patch)) !== null) {
3320
+ const normalized = normalizeTrackedPath(match[1] || '');
3321
+ if (normalized)
3322
+ rememberFileEntry(accessedFiles, normalized, ts, 'edit');
3323
+ }
3324
+ }
3325
+ function collectFileAccessFromToolCall(rawName, rawInput, ts, accessedFiles, accessedUrls) {
3326
+ const toolName = normalizeToolName(rawName);
3327
+ const input = parseToolInput(rawInput);
3328
+ const action = classifyToolAction(toolName);
3329
+ collectWebsiteUrls(input, accessedUrls, ts);
3330
+ rememberToolPathValue(accessedFiles, input.file_path, ts, action);
3331
+ rememberToolPathValue(accessedFiles, input.path, ts, action);
3332
+ rememberToolPathValue(accessedFiles, input.paths, ts, action);
3333
+ rememberToolPathValue(accessedFiles, input.oldPath, ts, action);
3334
+ rememberToolPathValue(accessedFiles, input.newPath, ts, action);
3335
+ rememberToolPathValue(accessedFiles, input.confirmPath, ts, action);
3336
+ if (toolName === 'grep') {
3337
+ rememberToolPathValue(accessedFiles, input.path, ts, 'read');
3338
+ }
3339
+ if (toolName === 'bash' || toolName === 'exec_command' || toolName.endsWith('.exec_command')) {
3340
+ const command = typeof input.command === 'string'
3341
+ ? input.command
3342
+ : (typeof input.cmd === 'string' ? input.cmd : '');
3343
+ collectFileAccessFromCommand(command, ts, accessedFiles, accessedUrls);
3344
+ }
3345
+ if (toolName === 'apply_patch' || toolName.endsWith('.apply_patch')) {
3346
+ const patchText = typeof input.input === 'string'
3347
+ ? input.input
3348
+ : (typeof input.patch === 'string' ? input.patch : '');
3349
+ collectFileAccessFromPatch(patchText, ts, accessedFiles);
3350
+ }
3351
+ }
2835
3352
  /**
2836
3353
  * Tail-read the last ~16KB of a file and return the last parseable
2837
3354
  * non-snapshot JSONL entry, last user prompt, and recently accessed files.
@@ -2854,106 +3371,171 @@ function tailLastJsonlEntry(filePath) {
2854
3371
  const text = buf.toString('utf-8');
2855
3372
  const lines = text.split('\n').filter(l => l.trim().length > 0);
2856
3373
  let lastEntry = null;
3374
+ let lastEntryTsMs = 0;
2857
3375
  let lastUserPrompt = '';
2858
3376
  const accessedFiles = new Map();
2859
3377
  const accessedUrls = new Map();
3378
+ let timestampOffsetMs = null;
2860
3379
  // Walk backwards through all parseable lines
2861
3380
  for (let i = lines.length - 1; i >= 0; i--) {
2862
3381
  try {
2863
3382
  const obj = JSON.parse(lines[i]);
2864
- if (obj.type === 'summary' || obj.type === 'snapshot' || obj.type === 'file-history-snapshot')
3383
+ const entryType = String(obj.type || '');
3384
+ if (entryType === 'summary' || entryType === 'snapshot' || entryType === 'file-history-snapshot')
2865
3385
  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();
3386
+ const payload = (obj.payload && typeof obj.payload === 'object' && !Array.isArray(obj.payload))
3387
+ ? obj.payload
3388
+ : null;
3389
+ const parsed = typeof obj.timestamp === 'string' ? new Date(obj.timestamp).getTime() : 0;
3390
+ const hasParsedTimestamp = parsed && !isNaN(parsed);
3391
+ if (timestampOffsetMs === null && hasParsedTimestamp) {
3392
+ timestampOffsetMs = computeTranscriptTimestampOffsetMs(parsed, st.mtimeMs);
3393
+ }
3394
+ const ts = hasParsedTimestamp
3395
+ ? applyTranscriptTimestampOffset(parsed, timestampOffsetMs || 0)
3396
+ : Date.now();
2870
3397
  // 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
- }
3398
+ if (entryType === 'assistant') {
3399
+ const message = (obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message))
3400
+ ? obj.message
3401
+ : null;
3402
+ const content = message?.content;
3403
+ if (Array.isArray(content)) {
3404
+ for (const block of content) {
3405
+ if (!block || typeof block !== 'object' || Array.isArray(block))
3406
+ continue;
3407
+ const typedBlock = block;
3408
+ if (typedBlock.type !== 'tool_use')
3409
+ continue;
3410
+ collectFileAccessFromToolCall(typedBlock.name, typedBlock.input, ts, accessedFiles, accessedUrls);
2920
3411
  }
2921
3412
  }
2922
3413
  }
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);
3414
+ // Codex transcript format: response_item.function_call entries
3415
+ if (entryType === 'response_item' && payload) {
3416
+ const payloadType = String(payload.type || '').toLowerCase();
3417
+ if (payloadType === 'function_call') {
3418
+ collectFileAccessFromToolCall(payload.name, payload.arguments, ts, accessedFiles, accessedUrls);
3419
+ if (!lastEntry) {
3420
+ lastEntry = {
3421
+ type: 'assistant',
3422
+ timestamp: obj.timestamp || null,
3423
+ message: {
3424
+ content: [{
3425
+ type: 'tool_use',
3426
+ name: String(payload.name || ''),
3427
+ input: parseToolInput(payload.arguments),
3428
+ }],
3429
+ },
3430
+ };
3431
+ lastEntryTsMs = ts;
3432
+ }
3433
+ }
3434
+ else if (payloadType === 'function_call_output') {
3435
+ const outputText = extractTextContent(payload.output);
3436
+ const pathMatches = outputText.match(/\/work\/[^\s:]+/g);
2931
3437
  if (pathMatches) {
2932
- for (const p of pathMatches.slice(0, 20)) { // cap to avoid perf issues
3438
+ for (const p of pathMatches.slice(0, 20)) {
2933
3439
  const clean = p.replace(/['"`;,)}\]]+$/, '');
2934
3440
  rememberFileEntry(accessedFiles, clean, ts, 'unknown');
2935
3441
  }
2936
3442
  }
2937
- for (const url of extractWebsiteUrlsFromText(text).slice(0, 30)) {
3443
+ for (const url of extractWebsiteUrlsFromText(outputText).slice(0, 30)) {
2938
3444
  rememberTimestampedEntry(accessedUrls, url, ts);
2939
3445
  }
2940
3446
  }
3447
+ else if (payloadType === 'message' && !lastEntry) {
3448
+ const role = String(payload.role || '').toLowerCase();
3449
+ if (role === 'assistant' || role === 'user') {
3450
+ lastEntry = {
3451
+ type: role,
3452
+ timestamp: obj.timestamp || null,
3453
+ message: { content: Array.isArray(payload.content) ? payload.content : [] },
3454
+ };
3455
+ lastEntryTsMs = ts;
3456
+ }
3457
+ }
3458
+ }
3459
+ // Also extract paths from tool_result content (file listings, etc.)
3460
+ if (entryType === 'user') {
3461
+ const message = (obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message))
3462
+ ? obj.message
3463
+ : null;
3464
+ const content = message?.content;
3465
+ if (Array.isArray(content)) {
3466
+ for (const block of content) {
3467
+ if (!block || typeof block !== 'object' || Array.isArray(block))
3468
+ continue;
3469
+ const typedBlock = block;
3470
+ if (typedBlock.type !== 'tool_result')
3471
+ continue;
3472
+ const blockText = extractTextContent(typedBlock.content);
3473
+ // Look for /work/ paths in tool output
3474
+ const pathMatches = blockText.match(/\/work\/[^\s:]+/g);
3475
+ if (pathMatches) {
3476
+ for (const p of pathMatches.slice(0, 20)) { // cap to avoid perf issues
3477
+ const clean = p.replace(/['"`;,)}\]]+$/, '');
3478
+ rememberFileEntry(accessedFiles, clean, ts, 'unknown');
3479
+ }
3480
+ }
3481
+ for (const url of extractWebsiteUrlsFromText(blockText).slice(0, 30)) {
3482
+ rememberTimestampedEntry(accessedUrls, url, ts);
3483
+ }
3484
+ }
3485
+ }
2941
3486
  }
2942
3487
  // Find the last real user prompt
2943
- if (!lastUserPrompt && obj.type === 'user' && !obj.isMeta) {
2944
- const content = obj.message?.content;
3488
+ if (!lastUserPrompt && entryType === 'user' && !obj.isMeta) {
3489
+ const message = (obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message))
3490
+ ? obj.message
3491
+ : null;
3492
+ const content = message?.content;
2945
3493
  if (typeof content === 'string' && content.length > 0) {
2946
3494
  if (!content.includes('<local-command-caveat>')) {
2947
3495
  lastUserPrompt = content.slice(0, 500);
2948
3496
  }
2949
3497
  }
2950
3498
  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>')) {
3499
+ const textBlock = content.find((b) => {
3500
+ if (!b || typeof b !== 'object' || Array.isArray(b))
3501
+ return false;
3502
+ const typed = b;
3503
+ return typed.type === 'text' && typeof typed.text === 'string' && typed.text.length > 0;
3504
+ });
3505
+ if (textBlock && typeof textBlock.text === 'string' && !textBlock.text.includes('<local-command-caveat>')) {
2953
3506
  lastUserPrompt = textBlock.text.slice(0, 500);
2954
3507
  }
2955
3508
  }
2956
3509
  }
3510
+ // Codex transcript format: response_item.message entries carry prompts as input_text blocks.
3511
+ if (!lastUserPrompt && entryType === 'response_item' && payload) {
3512
+ const payloadType = String(payload.type || '').toLowerCase();
3513
+ const role = String(payload.role || '').toLowerCase();
3514
+ if (payloadType === 'message' && role === 'user') {
3515
+ const content = payload.content;
3516
+ if (typeof content === 'string' && !content.includes('<local-command-caveat>')) {
3517
+ lastUserPrompt = content.slice(0, 500);
3518
+ }
3519
+ else if (Array.isArray(content)) {
3520
+ const textBlock = content.find((b) => {
3521
+ if (!b || typeof b !== 'object' || Array.isArray(b))
3522
+ return false;
3523
+ const typed = b;
3524
+ if (typeof typed.text !== 'string' || typed.text.length === 0)
3525
+ return false;
3526
+ const blockType = String(typed.type || '');
3527
+ return blockType === 'input_text' || blockType === 'text';
3528
+ });
3529
+ if (textBlock && typeof textBlock.text === 'string' && !textBlock.text.includes('<local-command-caveat>')) {
3530
+ lastUserPrompt = textBlock.text.slice(0, 500);
3531
+ }
3532
+ }
3533
+ }
3534
+ }
3535
+ if (!lastEntry && (entryType === 'assistant' || entryType === 'user' || entryType === 'human')) {
3536
+ lastEntry = obj;
3537
+ lastEntryTsMs = ts;
3538
+ }
2957
3539
  }
2958
3540
  catch {
2959
3541
  // Line may be truncated at the start of our read window — skip it
@@ -2961,7 +3543,14 @@ function tailLastJsonlEntry(filePath) {
2961
3543
  }
2962
3544
  if (!lastEntry)
2963
3545
  return null;
2964
- return { entry: lastEntry, lastUserPrompt, accessedFiles, accessedUrls, mtimeMs: st.mtimeMs };
3546
+ return {
3547
+ entry: lastEntry,
3548
+ entryTsMs: lastEntryTsMs,
3549
+ lastUserPrompt,
3550
+ accessedFiles,
3551
+ accessedUrls,
3552
+ mtimeMs: st.mtimeMs,
3553
+ };
2965
3554
  }
2966
3555
  catch {
2967
3556
  return null;
@@ -3020,7 +3609,12 @@ function scanJsonlFiles(root, maxDepth = 4, maxEntries = JSONL_SCAN_MAX_ENTRIES)
3020
3609
  continue;
3021
3610
  }
3022
3611
  if (entry.endsWith('.jsonl')) {
3023
- files.push({ path: fullPath, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs });
3612
+ files.push({
3613
+ path: fullPath,
3614
+ mtimeMs: st.mtimeMs,
3615
+ ctimeMs: st.ctimeMs,
3616
+ birthtimeMs: st.birthtimeMs,
3617
+ });
3024
3618
  }
3025
3619
  }
3026
3620
  catch {
@@ -3034,6 +3628,81 @@ function scanJsonlFiles(root, maxDepth = 4, maxEntries = JSONL_SCAN_MAX_ENTRIES)
3034
3628
  }
3035
3629
  return files;
3036
3630
  }
3631
+ function normalizeFileTimestampMs(value) {
3632
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
3633
+ }
3634
+ function computeTranscriptTimestampOffsetMs(transcriptTsMs, fileMtimeMs) {
3635
+ const transcriptMs = normalizeFileTimestampMs(transcriptTsMs);
3636
+ const mtimeMs = normalizeFileTimestampMs(fileMtimeMs);
3637
+ if (!transcriptMs || !mtimeMs)
3638
+ return 0;
3639
+ const offsetMs = mtimeMs - transcriptMs;
3640
+ if (Math.abs(offsetMs) < (2 * 60 * 1000))
3641
+ return 0;
3642
+ if (Math.abs(offsetMs) > (12 * 60 * 60 * 1000))
3643
+ return 0;
3644
+ return offsetMs;
3645
+ }
3646
+ function applyTranscriptTimestampOffset(tsMs, offsetMs) {
3647
+ const ts = normalizeFileTimestampMs(tsMs);
3648
+ if (!ts || !offsetMs)
3649
+ return ts;
3650
+ const adjusted = ts + offsetMs;
3651
+ return adjusted > 0 ? adjusted : ts;
3652
+ }
3653
+ function getStableJsonlBirthtimeMs(file) {
3654
+ const birthtimeMs = normalizeFileTimestampMs(file.birthtimeMs);
3655
+ if (!birthtimeMs)
3656
+ return 0;
3657
+ const ctimeMs = normalizeFileTimestampMs(file.ctimeMs);
3658
+ const mtimeMs = normalizeFileTimestampMs(file.mtimeMs);
3659
+ if (mtimeMs && birthtimeMs > (mtimeMs + 1_000))
3660
+ return 0;
3661
+ // Some filesystems synthesize birthtime from ctime; reject that once the
3662
+ // file has clearly been modified after creation.
3663
+ if (ctimeMs &&
3664
+ Math.abs(birthtimeMs - ctimeMs) <= 1_000 &&
3665
+ mtimeMs &&
3666
+ Math.abs(mtimeMs - ctimeMs) > 1_000) {
3667
+ return 0;
3668
+ }
3669
+ return birthtimeMs;
3670
+ }
3671
+ function selectTranscriptFileForSession(sessionStartMs, jsonlFiles) {
3672
+ if (!Number.isFinite(sessionStartMs) || sessionStartMs <= 0 || jsonlFiles.length === 0) {
3673
+ return null;
3674
+ }
3675
+ const birthCandidates = jsonlFiles
3676
+ .map((file) => {
3677
+ const birthtimeMs = getStableJsonlBirthtimeMs(file);
3678
+ const mtimeMs = normalizeFileTimestampMs(file.mtimeMs);
3679
+ return {
3680
+ file,
3681
+ birthtimeMs,
3682
+ birthDelta: birthtimeMs > 0 ? Math.abs(birthtimeMs - sessionStartMs) : Number.POSITIVE_INFINITY,
3683
+ mtimeDelta: Math.abs(mtimeMs - sessionStartMs),
3684
+ };
3685
+ })
3686
+ .filter((entry) => entry.birthtimeMs > 0)
3687
+ .sort((a, b) => a.birthDelta - b.birthDelta
3688
+ || a.mtimeDelta - b.mtimeDelta
3689
+ || b.file.mtimeMs - a.file.mtimeMs);
3690
+ if (birthCandidates.length > 0) {
3691
+ return birthCandidates[0].file;
3692
+ }
3693
+ const futureByMtime = [...jsonlFiles]
3694
+ .filter((file) => normalizeFileTimestampMs(file.mtimeMs) >= sessionStartMs)
3695
+ .sort((a, b) => (normalizeFileTimestampMs(a.mtimeMs) - sessionStartMs)
3696
+ - (normalizeFileTimestampMs(b.mtimeMs) - sessionStartMs)
3697
+ || b.mtimeMs - a.mtimeMs);
3698
+ if (futureByMtime.length > 0) {
3699
+ return futureByMtime[0];
3700
+ }
3701
+ const closestByMtime = [...jsonlFiles].sort((a, b) => Math.abs(normalizeFileTimestampMs(a.mtimeMs) - sessionStartMs)
3702
+ - Math.abs(normalizeFileTimestampMs(b.mtimeMs) - sessionStartMs)
3703
+ || b.mtimeMs - a.mtimeMs);
3704
+ return closestByMtime[0] || null;
3705
+ }
3037
3706
  /**
3038
3707
  * Scan sandbox home for agent conversation JSONL files.
3039
3708
  * Results are cached briefly to keep `/api/sessions` responsive.
@@ -3068,33 +3737,34 @@ function findProjectJsonlFiles(agent) {
3068
3737
  */
3069
3738
  function extractToolDetailFromToolUseBlock(block) {
3070
3739
  const name = String(block.name || '');
3740
+ const normalizedName = name.toLowerCase();
3071
3741
  const inputRaw = block.input;
3072
3742
  const input = inputRaw && typeof inputRaw === 'object' && !Array.isArray(inputRaw)
3073
3743
  ? inputRaw
3074
3744
  : {};
3075
- if (name === 'Bash' || name === 'bash') {
3745
+ if (normalizedName === 'bash') {
3076
3746
  const cmd = String(input.command || '').slice(0, 60);
3077
3747
  return cmd ? `Ran \`${cmd}\`` : 'Running Bash';
3078
3748
  }
3079
- if (name === 'Edit' || name === 'edit') {
3749
+ if (normalizedName === 'edit' || normalizedName === 'multiedit') {
3080
3750
  const file = String(input.file_path || '').split('/').pop() || '';
3081
3751
  return file ? `Edited ${file}` : 'Editing a file';
3082
3752
  }
3083
- if (name === 'Read' || name === 'read') {
3753
+ if (normalizedName === 'read') {
3084
3754
  const file = String(input.file_path || '').split('/').pop() || '';
3085
3755
  return file ? `Read ${file}` : 'Reading a file';
3086
3756
  }
3087
- if (name === 'Write' || name === 'write') {
3757
+ if (normalizedName === 'write') {
3088
3758
  const file = String(input.file_path || '').split('/').pop() || '';
3089
3759
  return file ? `Wrote ${file}` : 'Writing a file';
3090
3760
  }
3091
- if (name === 'Grep' || name === 'grep') {
3761
+ if (normalizedName === 'grep') {
3092
3762
  return `Searching for "${String(input.pattern || '').slice(0, 40)}"`;
3093
3763
  }
3094
- if (name === 'Glob' || name === 'glob') {
3764
+ if (normalizedName === 'glob') {
3095
3765
  return `Finding files: ${String(input.pattern || '').slice(0, 40)}`;
3096
3766
  }
3097
- if (name === 'Task' || name === 'task') {
3767
+ if (normalizedName === 'task') {
3098
3768
  return 'Spawned subagent';
3099
3769
  }
3100
3770
  return `Using ${name}`;
@@ -3253,6 +3923,9 @@ function getAccessedFiles(accessedFiles, workdir) {
3253
3923
  if (!rel || seen.has(rel))
3254
3924
  continue;
3255
3925
  seen.add(rel);
3926
+ const mappedPath = p.startsWith('/work/')
3927
+ ? (workdir ? (0, path_1.join)(workdir, rel) : p)
3928
+ : p;
3256
3929
  let isDir = false;
3257
3930
  if (p.startsWith('/work/')) {
3258
3931
  const hostPath = (0, path_1.join)(workdir, rel);
@@ -3272,7 +3945,7 @@ function getAccessedFiles(accessedFiles, workdir) {
3272
3945
  }
3273
3946
  files.push({
3274
3947
  name: rel,
3275
- path: p,
3948
+ path: mappedPath,
3276
3949
  accessedAt: entry.ts,
3277
3950
  isDir,
3278
3951
  action: entry.action,
@@ -3352,7 +4025,7 @@ function pruneSessionActivityCache(now = Date.now()) {
3352
4025
  }
3353
4026
  /**
3354
4027
  * Determine an agent session's current activity by reading transcript JSONL.
3355
- * Correlates session → JSONL by matching session start time with file creation time.
4028
+ * Correlates session → JSONL by matching session start time with transcript creation time.
3356
4029
  */
3357
4030
  function getAgentActivity(session) {
3358
4031
  const unknown = createUnknownActivity();
@@ -3364,20 +4037,16 @@ function getAgentActivity(session) {
3364
4037
  const sessionStartMs = session.started ? new Date(session.started).getTime() : 0;
3365
4038
  if (!sessionStartMs)
3366
4039
  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
- }
4040
+ const bestFile = selectTranscriptFileForSession(sessionStartMs, jsonlFiles);
3372
4041
  if (!bestFile)
3373
4042
  return unknown;
3374
4043
  const result = tailLastJsonlEntry(bestFile.path);
3375
4044
  if (!result)
3376
4045
  return unknown;
3377
- const { entry, lastUserPrompt, accessedFiles, accessedUrls, mtimeMs } = result;
4046
+ const { entry, entryTsMs, lastUserPrompt, accessedFiles, accessedUrls, mtimeMs } = result;
3378
4047
  const age = Date.now() - mtimeMs;
3379
4048
  const type = entry.type || '';
3380
- const entryTs = entry.timestamp ? new Date(entry.timestamp).getTime() : mtimeMs;
4049
+ const entryTs = entryTsMs || mtimeMs;
3381
4050
  // Merge tail-read file accesses into persistent per-session history
3382
4051
  const mergedHistory = mergeFileHistory(session.id || '', accessedFiles);
3383
4052
  const files = getAccessedFiles(mergedHistory, session.workdir || '');
@@ -4091,23 +4760,520 @@ function handleGetClaudeAuthStatus(_req, res) {
4091
4760
  checkedAt: new Date().toISOString(),
4092
4761
  });
4093
4762
  }
4094
- async function handleStopSession(req, res) {
4763
+ function isClaudeAuthHost(hostnameRaw) {
4764
+ const host = String(hostnameRaw || '').trim().toLowerCase();
4765
+ return host === 'claude.ai' || host === 'platform.claude.com' || host === 'console.anthropic.com';
4766
+ }
4767
+ function isLikelyClaudeAuthPath(pathnameRaw) {
4768
+ const path = String(pathnameRaw || '').trim().toLowerCase();
4769
+ if (!path)
4770
+ return false;
4771
+ return path.startsWith('/oauth')
4772
+ || path.startsWith('/login')
4773
+ || path.startsWith('/auth')
4774
+ || path.startsWith('/code/callback');
4775
+ }
4776
+ function maybeRewriteClaudeOauthRedirectUri(parsed) {
4777
+ const rawRedirectUri = (parsed.searchParams.get('redirect_uri') || '').trim();
4778
+ if (!rawRedirectUri)
4779
+ return;
4095
4780
  try {
4096
- const body = await readBody(req);
4097
- const { id } = JSON.parse(body);
4098
- if (!id) {
4099
- json(res, { ok: false, error: 'Missing session id' }, 400);
4100
- return;
4781
+ const redirectUri = new URL(rawRedirectUri);
4782
+ const host = String(redirectUri.hostname || '').trim().toLowerCase();
4783
+ if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1') {
4784
+ parsed.searchParams.set('redirect_uri', 'https://platform.claude.com/oauth/code/callback');
4101
4785
  }
4102
- const dir = (0, config_js_1.getSessionsDir)();
4103
- const localHost = (0, os_1.hostname)();
4104
- const sessionFile = (0, path_1.join)(dir, id + '.json');
4105
- if (!(0, fs_1.existsSync)(sessionFile)) {
4106
- json(res, { ok: false, error: 'Session not found' }, 404);
4107
- return;
4786
+ }
4787
+ catch {
4788
+ // Keep original redirect URI when parsing fails.
4789
+ }
4790
+ }
4791
+ function normalizeClaudeAuthUrlCandidate(candidate) {
4792
+ if (!candidate)
4793
+ return null;
4794
+ try {
4795
+ const parsed = new URL(String(candidate || '').trim());
4796
+ if (parsed.protocol !== 'https:')
4797
+ return null;
4798
+ if (!isClaudeAuthHost(parsed.hostname))
4799
+ return null;
4800
+ if (!isLikelyClaudeAuthPath(parsed.pathname))
4801
+ return null;
4802
+ if (parsed.hostname.toLowerCase() === 'claude.ai' && parsed.pathname === '/oauth/authorize') {
4803
+ maybeRewriteClaudeOauthRedirectUri(parsed);
4108
4804
  }
4109
- const data = JSON.parse((0, fs_1.readFileSync)(sessionFile, 'utf-8'));
4110
- if (data.node !== localHost) {
4805
+ return parsed.toString();
4806
+ }
4807
+ catch {
4808
+ return null;
4809
+ }
4810
+ }
4811
+ function normalizeClaudeOauthUrlFromText(raw) {
4812
+ const source = String(raw || '');
4813
+ const osc8Urls = [];
4814
+ const osc8Re = /\x1b\]8;[^\x07\x1b]*?;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
4815
+ for (const match of source.matchAll(osc8Re)) {
4816
+ const candidate = String(match[1] || '').trim();
4817
+ if (candidate)
4818
+ osc8Urls.push(candidate);
4819
+ }
4820
+ let cleaned = source
4821
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
4822
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
4823
+ .replace(/\x1b[P^_][\s\S]*?\x1b\\/g, '')
4824
+ .replace(/\x1b[@-_]/g, '')
4825
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
4826
+ if (osc8Urls.length) {
4827
+ cleaned += '\n' + osc8Urls.join('\n');
4828
+ }
4829
+ if (!cleaned)
4830
+ return null;
4831
+ const strictMatch = cleaned.match(/https:\/\/claude\.ai\/oauth\/authorize\?[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]+/);
4832
+ if (strictMatch && strictMatch[0]) {
4833
+ const strictUrl = normalizeClaudeAuthUrlCandidate(strictMatch[0]);
4834
+ if (strictUrl)
4835
+ return strictUrl;
4836
+ }
4837
+ const fallbackMatches = cleaned.match(CLAUDE_AUTH_FALLBACK_URL_RE) || [];
4838
+ for (const match of fallbackMatches) {
4839
+ const normalized = normalizeClaudeAuthUrlCandidate(match);
4840
+ if (normalized)
4841
+ return normalized;
4842
+ }
4843
+ return null;
4844
+ }
4845
+ function stripTerminalControlForClaudeAuthFlow(text) {
4846
+ return String(text || '')
4847
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
4848
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
4849
+ .replace(/\x1b[P^_][\s\S]*?\x1b\\/g, '')
4850
+ .replace(/\x1b[@-_]/g, '')
4851
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
4852
+ }
4853
+ function sanitizeClaudeOauthCodeForCli(raw) {
4854
+ let text = stripTerminalControlForClaudeAuthFlow(String(raw || ''))
4855
+ .replace(/^["'\s]+|["'\s]+$/g, '')
4856
+ .trim();
4857
+ if (!text)
4858
+ return '';
4859
+ const hashCodeMatch = text.match(/^([A-Za-z0-9._~-]{24,})#[A-Za-z0-9._~-]{6,}$/);
4860
+ if (hashCodeMatch && hashCodeMatch[1]) {
4861
+ return hashCodeMatch[1].trim();
4862
+ }
4863
+ const urlInText = text.match(/https?:\/\/[^\s"'<>]+/i);
4864
+ if (urlInText && urlInText[0]) {
4865
+ try {
4866
+ const parsedUrl = new URL(urlInText[0]);
4867
+ const codeFromUrl = (parsedUrl.searchParams.get('code') || '').trim();
4868
+ if (codeFromUrl)
4869
+ return codeFromUrl;
4870
+ }
4871
+ catch {
4872
+ // Continue with regex fallback below.
4873
+ }
4874
+ }
4875
+ const hashOnlyMatch = text.match(/^([A-Za-z0-9._~-]{24,})#/);
4876
+ if (hashOnlyMatch && hashOnlyMatch[1]) {
4877
+ return hashOnlyMatch[1].trim();
4878
+ }
4879
+ const codeMatch = text.match(/[?&#]code=([^&#\s]+)/i) || text.match(/\bcode=([^\s&]+)/i);
4880
+ if (codeMatch && codeMatch[1]) {
4881
+ try {
4882
+ return decodeURIComponent(codeMatch[1]).trim();
4883
+ }
4884
+ catch {
4885
+ return codeMatch[1].trim();
4886
+ }
4887
+ }
4888
+ return text;
4889
+ }
4890
+ function resolveClaudeCliBinaryForSandboxHome(sandboxHome) {
4891
+ const fromSandboxHome = (0, path_1.join)(sandboxHome, '.npm-global', 'bin', 'claude');
4892
+ if ((0, fs_1.existsSync)(fromSandboxHome))
4893
+ return fromSandboxHome;
4894
+ return 'claude';
4895
+ }
4896
+ function notifyClaudeAuthFlowUrlWaiters(flow, url) {
4897
+ if (!flow.urlWaiters.length)
4898
+ return;
4899
+ const waiters = flow.urlWaiters.splice(0, flow.urlWaiters.length);
4900
+ for (const waiter of waiters) {
4901
+ try {
4902
+ waiter(url);
4903
+ }
4904
+ catch { /* best effort */ }
4905
+ }
4906
+ }
4907
+ function notifyClaudeAuthFlowResultWaiters(flow, ok) {
4908
+ if (!flow.resultWaiters.length)
4909
+ return;
4910
+ const waiters = flow.resultWaiters.splice(0, flow.resultWaiters.length);
4911
+ for (const waiter of waiters) {
4912
+ try {
4913
+ waiter(ok);
4914
+ }
4915
+ catch { /* best effort */ }
4916
+ }
4917
+ }
4918
+ function pruneClaudeAuthLoginFlows() {
4919
+ const now = Date.now();
4920
+ for (const [sessionId, flow] of claudeAuthLoginFlows.entries()) {
4921
+ const ageMs = now - flow.updatedAtMs;
4922
+ if (!flow.finished && ageMs <= CLAUDE_AUTH_FLOW_IDLE_TTL_MS)
4923
+ continue;
4924
+ if (!flow.finished) {
4925
+ try {
4926
+ flow.child.kill('SIGTERM');
4927
+ }
4928
+ catch { /* best effort */ }
4929
+ }
4930
+ notifyClaudeAuthFlowUrlWaiters(flow, flow.url || null);
4931
+ notifyClaudeAuthFlowResultWaiters(flow, flow.success === true);
4932
+ claudeAuthLoginFlows.delete(sessionId);
4933
+ }
4934
+ }
4935
+ function waitForClaudeAuthFlowUrl(flow, timeoutMs) {
4936
+ if (flow.url)
4937
+ return Promise.resolve(flow.url);
4938
+ if (flow.finished)
4939
+ return Promise.resolve(null);
4940
+ return new Promise((resolve) => {
4941
+ const waiter = (url) => {
4942
+ clearTimeout(timeout);
4943
+ resolve(url || null);
4944
+ };
4945
+ const timeout = setTimeout(() => {
4946
+ const idx = flow.urlWaiters.indexOf(waiter);
4947
+ if (idx >= 0)
4948
+ flow.urlWaiters.splice(idx, 1);
4949
+ resolve(flow.url || null);
4950
+ }, Math.max(500, timeoutMs));
4951
+ flow.urlWaiters.push(waiter);
4952
+ });
4953
+ }
4954
+ function waitForClaudeAuthFlowResult(flow, timeoutMs) {
4955
+ if (flow.success)
4956
+ return Promise.resolve(true);
4957
+ if (flow.finished)
4958
+ return Promise.resolve(false);
4959
+ return new Promise((resolve) => {
4960
+ const waiter = (ok) => {
4961
+ clearTimeout(timeout);
4962
+ resolve(ok);
4963
+ };
4964
+ const timeout = setTimeout(() => {
4965
+ const idx = flow.resultWaiters.indexOf(waiter);
4966
+ if (idx >= 0)
4967
+ flow.resultWaiters.splice(idx, 1);
4968
+ resolve(flow.success === true);
4969
+ }, Math.max(500, timeoutMs));
4970
+ flow.resultWaiters.push(waiter);
4971
+ });
4972
+ }
4973
+ function summarizeClaudeAuthFlowFailure(flow) {
4974
+ const plainBuffer = stripTerminalControlForClaudeAuthFlow(flow.buffer);
4975
+ const plainStderr = stripTerminalControlForClaudeAuthFlow(flow.stderr);
4976
+ const combined = (plainStderr + '\n' + plainBuffer)
4977
+ .split(/\r?\n/)
4978
+ .map((line) => line.trim())
4979
+ .filter((line) => line.length > 0);
4980
+ if (!combined.length) {
4981
+ return 'Claude login link not detected. Try again, or run `claude auth login` directly in the attached terminal.';
4982
+ }
4983
+ const sample = combined.slice(-6).join(' | ').slice(0, 420);
4984
+ return `Claude login link not detected. Last output: ${sample}`;
4985
+ }
4986
+ function summarizeClaudeAuthFlowCodeFailure(flow) {
4987
+ const plainBuffer = stripTerminalControlForClaudeAuthFlow(flow.buffer);
4988
+ const plainStderr = stripTerminalControlForClaudeAuthFlow(flow.stderr);
4989
+ const combined = (plainStderr + '\n' + plainBuffer)
4990
+ .split(/\r?\n/)
4991
+ .map((line) => line.trim())
4992
+ .filter((line) => line.length > 0);
4993
+ if (!combined.length) {
4994
+ return 'Claude login confirmation was not detected. The code may be invalid or expired.';
4995
+ }
4996
+ const sample = combined.slice(-6).join(' | ').slice(0, 420);
4997
+ return `Claude login confirmation was not detected. Last output: ${sample}`;
4998
+ }
4999
+ function startClaudeAuthLoginFlow(sessionId, workdir) {
5000
+ pruneClaudeAuthLoginFlows();
5001
+ const existing = claudeAuthLoginFlows.get(sessionId);
5002
+ if (existing && !existing.finished)
5003
+ return existing;
5004
+ if (existing) {
5005
+ try {
5006
+ existing.child.kill('SIGTERM');
5007
+ }
5008
+ catch { /* best effort */ }
5009
+ claudeAuthLoginFlows.delete(sessionId);
5010
+ }
5011
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
5012
+ const claudeBinary = resolveClaudeCliBinaryForSandboxHome(sandboxHome);
5013
+ const env = {
5014
+ ...process.env,
5015
+ HOME: sandboxHome,
5016
+ };
5017
+ const child = (0, child_process_1.spawn)(claudeBinary, ['auth', 'login'], {
5018
+ cwd: workdir || sandboxHome,
5019
+ env,
5020
+ stdio: ['pipe', 'pipe', 'pipe'],
5021
+ });
5022
+ const flow = {
5023
+ sessionId,
5024
+ child,
5025
+ startedAtMs: Date.now(),
5026
+ updatedAtMs: Date.now(),
5027
+ buffer: '',
5028
+ stderr: '',
5029
+ url: null,
5030
+ finished: false,
5031
+ success: false,
5032
+ exitCode: null,
5033
+ codeSubmitted: false,
5034
+ urlWaiters: [],
5035
+ resultWaiters: [],
5036
+ };
5037
+ claudeAuthLoginFlows.set(sessionId, flow);
5038
+ const handleOutput = (text, isStderr) => {
5039
+ if (!text)
5040
+ return;
5041
+ flow.updatedAtMs = Date.now();
5042
+ if (isStderr) {
5043
+ flow.stderr = (flow.stderr + text).slice(-CLAUDE_AUTH_FLOW_MAX_OUTPUT_CHARS);
5044
+ }
5045
+ flow.buffer = (flow.buffer + text).slice(-CLAUDE_AUTH_FLOW_MAX_OUTPUT_CHARS);
5046
+ if (!flow.url) {
5047
+ const normalizedUrl = normalizeClaudeOauthUrlFromText(flow.buffer);
5048
+ if (normalizedUrl) {
5049
+ flow.url = normalizedUrl;
5050
+ notifyClaudeAuthFlowUrlWaiters(flow, normalizedUrl);
5051
+ }
5052
+ }
5053
+ if (!flow.success) {
5054
+ const plainChunk = stripTerminalControlForClaudeAuthFlow(text);
5055
+ if (CLAUDE_LOGIN_SUCCESS_RE.test(plainChunk)) {
5056
+ flow.success = true;
5057
+ notifyClaudeAuthFlowResultWaiters(flow, true);
5058
+ }
5059
+ }
5060
+ };
5061
+ child.stdout.on('data', (chunk) => {
5062
+ handleOutput(chunk.toString('utf-8'), false);
5063
+ });
5064
+ child.stderr.on('data', (chunk) => {
5065
+ handleOutput(chunk.toString('utf-8'), true);
5066
+ });
5067
+ child.on('error', (err) => {
5068
+ flow.finished = true;
5069
+ flow.updatedAtMs = Date.now();
5070
+ flow.stderr = (flow.stderr + '\n' + String(err.message || err)).slice(-CLAUDE_AUTH_FLOW_MAX_OUTPUT_CHARS);
5071
+ notifyClaudeAuthFlowUrlWaiters(flow, flow.url || null);
5072
+ notifyClaudeAuthFlowResultWaiters(flow, false);
5073
+ });
5074
+ child.on('close', (code) => {
5075
+ flow.finished = true;
5076
+ flow.updatedAtMs = Date.now();
5077
+ flow.exitCode = Number.isFinite(code) ? Math.trunc(code) : null;
5078
+ if (flow.codeSubmitted && !flow.success && flow.exitCode === 0) {
5079
+ flow.success = true;
5080
+ }
5081
+ notifyClaudeAuthFlowUrlWaiters(flow, flow.url || null);
5082
+ notifyClaudeAuthFlowResultWaiters(flow, flow.success === true);
5083
+ });
5084
+ return flow;
5085
+ }
5086
+ function resolveClaudeAuthSessionForLocalNode(sessionIdRaw) {
5087
+ const sessionId = String(sessionIdRaw || '').trim();
5088
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(sessionId)) {
5089
+ return { ok: false, status: 400, error: 'Invalid terminal session id.' };
5090
+ }
5091
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(sessionId);
5092
+ if (!record) {
5093
+ return { ok: false, status: 404, error: 'Terminal session not found.' };
5094
+ }
5095
+ if (record.node !== (0, os_1.hostname)()) {
5096
+ return {
5097
+ ok: false,
5098
+ status: 400,
5099
+ error: `Terminal session is on a different node (${record.node})`,
5100
+ };
5101
+ }
5102
+ if (String(record.agent || '').toLowerCase() !== 'claude') {
5103
+ return { ok: false, status: 400, error: 'Terminal session is not a Claude session.' };
5104
+ }
5105
+ return { ok: true, record };
5106
+ }
5107
+ async function handlePostClaudeAuthLoginStart(req, res) {
5108
+ try {
5109
+ const body = JSON.parse(await readBody(req) || '{}');
5110
+ const resolved = resolveClaudeAuthSessionForLocalNode(body.sessionId || '');
5111
+ if (!resolved.ok) {
5112
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5113
+ return;
5114
+ }
5115
+ const sessionId = resolved.record.id;
5116
+ const flow = startClaudeAuthLoginFlow(sessionId, resolved.record.workdir);
5117
+ const url = await waitForClaudeAuthFlowUrl(flow, CLAUDE_AUTH_FLOW_URL_WAIT_TIMEOUT_MS);
5118
+ if (url) {
5119
+ json(res, {
5120
+ ok: true,
5121
+ sessionId,
5122
+ url,
5123
+ });
5124
+ return;
5125
+ }
5126
+ json(res, {
5127
+ ok: false,
5128
+ sessionId,
5129
+ error: summarizeClaudeAuthFlowFailure(flow),
5130
+ }, 504);
5131
+ if (!flow.finished) {
5132
+ try {
5133
+ flow.child.kill('SIGTERM');
5134
+ }
5135
+ catch { /* best effort */ }
5136
+ }
5137
+ if (claudeAuthLoginFlows.get(sessionId) === flow) {
5138
+ claudeAuthLoginFlows.delete(sessionId);
5139
+ }
5140
+ }
5141
+ catch (err) {
5142
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5143
+ }
5144
+ }
5145
+ async function handlePostClaudeAuthLoginCode(req, res) {
5146
+ try {
5147
+ const body = JSON.parse(await readBody(req) || '{}');
5148
+ const resolved = resolveClaudeAuthSessionForLocalNode(body.sessionId || '');
5149
+ if (!resolved.ok) {
5150
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5151
+ return;
5152
+ }
5153
+ const sessionId = resolved.record.id;
5154
+ const code = sanitizeClaudeOauthCodeForCli(String(body.code || ''));
5155
+ if (!code) {
5156
+ json(res, { ok: false, error: 'No login code detected.' }, 400);
5157
+ return;
5158
+ }
5159
+ const flow = claudeAuthLoginFlows.get(sessionId);
5160
+ if (!flow || flow.finished) {
5161
+ json(res, {
5162
+ ok: false,
5163
+ error: 'No active Claude login flow. Start /login again to request a fresh link.',
5164
+ }, 409);
5165
+ return;
5166
+ }
5167
+ flow.codeSubmitted = true;
5168
+ flow.updatedAtMs = Date.now();
5169
+ try {
5170
+ flow.child.stdin.write(code + '\n');
5171
+ }
5172
+ catch (err) {
5173
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5174
+ return;
5175
+ }
5176
+ const confirmed = await waitForClaudeAuthFlowResult(flow, CLAUDE_AUTH_FLOW_CONFIRM_TIMEOUT_MS);
5177
+ if (confirmed) {
5178
+ flow.success = true;
5179
+ flow.updatedAtMs = Date.now();
5180
+ try {
5181
+ flow.child.kill('SIGTERM');
5182
+ }
5183
+ catch { /* best effort */ }
5184
+ claudeAuthLoginFlows.delete(sessionId);
5185
+ json(res, { ok: true, sessionId, confirmed: true });
5186
+ return;
5187
+ }
5188
+ json(res, {
5189
+ ok: false,
5190
+ sessionId,
5191
+ error: summarizeClaudeAuthFlowCodeFailure(flow),
5192
+ }, 504);
5193
+ }
5194
+ catch (err) {
5195
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5196
+ }
5197
+ }
5198
+ function readClaudeOauthBrowserUrlSnapshot(consume, minUpdatedAtMs) {
5199
+ const candidates = [];
5200
+ for (const filePath of CLAUDE_BROWSER_URL_FILES) {
5201
+ if (!(0, fs_1.existsSync)(filePath))
5202
+ continue;
5203
+ try {
5204
+ const st = (0, fs_1.statSync)(filePath);
5205
+ const ageMs = Date.now() - st.mtimeMs;
5206
+ if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > CLAUDE_BROWSER_URL_MAX_AGE_MS)
5207
+ continue;
5208
+ if (minUpdatedAtMs !== null && minUpdatedAtMs > 0 && st.mtimeMs < minUpdatedAtMs) {
5209
+ continue;
5210
+ }
5211
+ const raw = (0, fs_1.readFileSync)(filePath, 'utf-8');
5212
+ candidates.push({
5213
+ path: filePath,
5214
+ mtimeMs: st.mtimeMs,
5215
+ updatedAt: new Date(st.mtimeMs).toISOString(),
5216
+ raw,
5217
+ });
5218
+ }
5219
+ catch {
5220
+ // Skip unreadable candidates.
5221
+ }
5222
+ }
5223
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
5224
+ let latestMeta = { updatedAt: null, path: null };
5225
+ for (const candidate of candidates) {
5226
+ latestMeta = { updatedAt: candidate.updatedAt, path: candidate.path };
5227
+ const normalizedUrl = normalizeClaudeOauthUrlFromText(candidate.raw);
5228
+ if (!normalizedUrl)
5229
+ continue;
5230
+ if (consume) {
5231
+ try {
5232
+ (0, fs_1.unlinkSync)(candidate.path);
5233
+ }
5234
+ catch { /* best effort */ }
5235
+ }
5236
+ return {
5237
+ url: normalizedUrl,
5238
+ updatedAt: candidate.updatedAt,
5239
+ path: candidate.path,
5240
+ };
5241
+ }
5242
+ return { url: null, updatedAt: latestMeta.updatedAt, path: latestMeta.path };
5243
+ }
5244
+ function handleGetClaudeOauthUrl(reqUrl, res) {
5245
+ const consumeRaw = String(reqUrl.searchParams.get('consume') || '').trim().toLowerCase();
5246
+ const consume = consumeRaw === '1' || consumeRaw === 'true' || consumeRaw === 'yes';
5247
+ const minUpdatedAtRaw = String(reqUrl.searchParams.get('minUpdatedAtMs') || '').trim();
5248
+ const parsedMinUpdatedAtMs = minUpdatedAtRaw ? Number(minUpdatedAtRaw) : NaN;
5249
+ const minUpdatedAtMs = Number.isFinite(parsedMinUpdatedAtMs) && parsedMinUpdatedAtMs > 0
5250
+ ? Math.floor(parsedMinUpdatedAtMs)
5251
+ : null;
5252
+ const snapshot = readClaudeOauthBrowserUrlSnapshot(consume, minUpdatedAtMs);
5253
+ json(res, {
5254
+ ok: true,
5255
+ url: snapshot.url,
5256
+ updatedAt: snapshot.updatedAt,
5257
+ path: snapshot.path,
5258
+ });
5259
+ }
5260
+ async function handleStopSession(req, res) {
5261
+ try {
5262
+ const body = await readBody(req);
5263
+ const { id } = JSON.parse(body);
5264
+ if (!id) {
5265
+ json(res, { ok: false, error: 'Missing session id' }, 400);
5266
+ return;
5267
+ }
5268
+ const dir = (0, config_js_1.getSessionsDir)();
5269
+ const localHost = (0, os_1.hostname)();
5270
+ const sessionFile = (0, path_1.join)(dir, id + '.json');
5271
+ if (!(0, fs_1.existsSync)(sessionFile)) {
5272
+ json(res, { ok: false, error: 'Session not found' }, 404);
5273
+ return;
5274
+ }
5275
+ const data = JSON.parse((0, fs_1.readFileSync)(sessionFile, 'utf-8'));
5276
+ if (data.node !== localHost) {
4111
5277
  json(res, { ok: false, error: 'Session is on a different node (' + data.node + ')' }, 400);
4112
5278
  return;
4113
5279
  }
@@ -4517,8 +5683,12 @@ async function handleGetWebTerminalSessions(res) {
4517
5683
  return Number.isFinite(ms) ? ms : 0;
4518
5684
  }
4519
5685
  const allSessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
4520
- const activeSessions = allSessions.filter((session) => session.status === 'running');
4521
- const recentInactive = allSessions
5686
+ const visibleSessions = allSessions.filter((session) => {
5687
+ const sessionNode = String(session.node || '').trim();
5688
+ return !sessionNode || sessionNode === localNode;
5689
+ });
5690
+ const activeSessions = visibleSessions.filter((session) => session.status === 'running');
5691
+ const recentInactive = visibleSessions
4522
5692
  .filter((session) => session.status !== 'running')
4523
5693
  .sort((a, b) => parseSessionSortMs(b) - parseSessionSortMs(a))
4524
5694
  .slice(0, 24);
@@ -4529,21 +5699,107 @@ async function handleGetWebTerminalSessions(res) {
4529
5699
  .map((job) => serializeWebTerminalInitJob(job));
4530
5700
  json(res, { ok: true, sessions, initJobs });
4531
5701
  }
4532
- async function handleGetWebTerminalHistory(reqUrl, res) {
4533
- const id = String(reqUrl.searchParams.get('id') || '').trim();
5702
+ async function getTmuxSessionForegroundCommand(tmuxSession) {
5703
+ const target = String(tmuxSession || '').trim();
5704
+ if (!target)
5705
+ return '';
5706
+ try {
5707
+ const tmuxBin = await (0, web_terminal_js_1.getTmuxBinary)();
5708
+ const { stdout } = await execFileAsync(tmuxBin, ['list-panes', '-t', target, '-F', '#{pane_active}\t#{pane_current_command}'], { timeout: 3_000 });
5709
+ const lines = String(stdout || '').split(/\r?\n/);
5710
+ let fallback = '';
5711
+ for (const line of lines) {
5712
+ if (!line)
5713
+ continue;
5714
+ const tabIndex = line.indexOf('\t');
5715
+ const active = tabIndex >= 0 ? line.slice(0, tabIndex).trim() : '';
5716
+ const command = tabIndex >= 0 ? line.slice(tabIndex + 1).trim() : line.trim();
5717
+ if (!command)
5718
+ continue;
5719
+ if (!fallback)
5720
+ fallback = command;
5721
+ if (active === '1')
5722
+ return command;
5723
+ }
5724
+ return fallback;
5725
+ }
5726
+ catch {
5727
+ return '';
5728
+ }
5729
+ }
5730
+ function resolveLocalWebTerminalRecord(idRaw) {
5731
+ const id = String(idRaw || '').trim();
4534
5732
  if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
4535
- json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
4536
- return;
5733
+ return { ok: false, status: 400, error: 'Invalid terminal session id' };
4537
5734
  }
4538
5735
  const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
4539
5736
  if (!record) {
4540
- json(res, { ok: false, error: 'Terminal session not found' }, 404);
4541
- return;
5737
+ return { ok: false, status: 404, error: 'Terminal session not found' };
4542
5738
  }
4543
5739
  if (record.node !== (0, os_1.hostname)()) {
4544
- json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
5740
+ return { ok: false, status: 400, error: `Terminal session is on a different node (${record.node})` };
5741
+ }
5742
+ return { ok: true, record };
5743
+ }
5744
+ function normalizeBookmarkField(raw, maxLength) {
5745
+ const text = typeof raw === 'string' ? raw.trim() : '';
5746
+ if (!text)
5747
+ return '';
5748
+ return text.slice(0, maxLength);
5749
+ }
5750
+ function normalizeBookmarkSeq(raw) {
5751
+ if (raw === null || raw === undefined || raw === '')
5752
+ return null;
5753
+ const value = Number(raw);
5754
+ if (!Number.isFinite(value))
5755
+ return null;
5756
+ return Math.max(0, Math.floor(value));
5757
+ }
5758
+ function normalizeBookmarkViewportHint(raw) {
5759
+ const value = Number(raw);
5760
+ if (!Number.isFinite(value))
5761
+ return 0;
5762
+ return Math.max(0, Math.floor(value));
5763
+ }
5764
+ function normalizeBookmarkHash(raw) {
5765
+ return normalizeBookmarkField(raw, 128);
5766
+ }
5767
+ function stableBookmarkHash(parts) {
5768
+ const normalized = parts
5769
+ .map((part) => String(part || '').trim().replace(/\s+/g, ' ').toLowerCase())
5770
+ .filter(Boolean)
5771
+ .join('\n');
5772
+ if (!normalized)
5773
+ return '';
5774
+ return (0, crypto_1.createHash)('sha1').update(normalized).digest('hex');
5775
+ }
5776
+ function isTerminalBookmarksFeatureEnabled() {
5777
+ return !TEMPORARILY_DISABLED_WEB_UI_FEATURES.terminalBookmarks;
5778
+ }
5779
+ function serializeWebTerminalBookmark(bookmark) {
5780
+ return {
5781
+ id: bookmark.id,
5782
+ sessionId: bookmark.sessionId,
5783
+ label: bookmark.label || '',
5784
+ createdAt: bookmark.createdAt,
5785
+ anchorText: bookmark.anchorText,
5786
+ previewText: bookmark.previewText,
5787
+ viewportYHint: bookmark.viewportYHint,
5788
+ aroundTopText: bookmark.aroundTopText || '',
5789
+ aroundBottomText: bookmark.aroundBottomText || '',
5790
+ latestSeqAtCapture: bookmark.latestSeqAtCapture,
5791
+ oldestSeqLoadedAtCapture: bookmark.oldestSeqLoadedAtCapture,
5792
+ anchorHash: bookmark.anchorHash || '',
5793
+ };
5794
+ }
5795
+ async function handleGetWebTerminalHistory(reqUrl, res) {
5796
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
5797
+ const resolvedRecord = resolveLocalWebTerminalRecord(id);
5798
+ if (!resolvedRecord.ok) {
5799
+ json(res, { ok: false, error: resolvedRecord.error }, resolvedRecord.status);
4545
5800
  return;
4546
5801
  }
5802
+ const record = resolvedRecord.record;
4547
5803
  const beforeRaw = String(reqUrl.searchParams.get('before') || '').trim();
4548
5804
  let beforeSeq = null;
4549
5805
  if (beforeRaw) {
@@ -4570,15 +5826,107 @@ async function handleGetWebTerminalHistory(reqUrl, res) {
4570
5826
  return;
4571
5827
  }
4572
5828
  const page = getWebTerminalHistoryPage(bridge, { beforeSeq, limit });
5829
+ const currentCommand = await getTmuxSessionForegroundCommand(record.tmuxSession);
4573
5830
  json(res, {
4574
5831
  ok: true,
4575
5832
  id: record.id,
5833
+ currentCommand: currentCommand || null,
4576
5834
  history: {
4577
5835
  ...page,
4578
5836
  limit,
4579
5837
  },
4580
5838
  });
4581
5839
  }
5840
+ async function handleGetWebTerminalBookmarks(reqUrl, res) {
5841
+ if (!isTerminalBookmarksFeatureEnabled()) {
5842
+ json(res, { ok: false, error: 'Terminal bookmarks are temporarily disabled.' }, 404);
5843
+ return;
5844
+ }
5845
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
5846
+ const resolved = resolveLocalWebTerminalRecord(id);
5847
+ if (!resolved.ok) {
5848
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5849
+ return;
5850
+ }
5851
+ const bookmarks = (0, web_terminal_js_1.readWebTerminalBookmarks)(resolved.record.id).map(serializeWebTerminalBookmark);
5852
+ json(res, { ok: true, id: resolved.record.id, bookmarks });
5853
+ }
5854
+ async function handlePostWebTerminalBookmarks(req, res) {
5855
+ if (!isTerminalBookmarksFeatureEnabled()) {
5856
+ json(res, { ok: false, error: 'Terminal bookmarks are temporarily disabled.' }, 404);
5857
+ return;
5858
+ }
5859
+ try {
5860
+ const body = await readBody(req);
5861
+ const parsed = JSON.parse(body || '{}');
5862
+ const id = String(parsed.id || '').trim();
5863
+ const resolved = resolveLocalWebTerminalRecord(id);
5864
+ if (!resolved.ok) {
5865
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5866
+ return;
5867
+ }
5868
+ const anchorText = normalizeBookmarkField(parsed.anchorText, 1_200);
5869
+ if (!anchorText) {
5870
+ json(res, { ok: false, error: 'anchorText is required' }, 400);
5871
+ return;
5872
+ }
5873
+ const previewText = normalizeBookmarkField(parsed.previewText, 2_000) || anchorText;
5874
+ const label = normalizeBookmarkField(parsed.label, 160);
5875
+ const aroundTopText = normalizeBookmarkField(parsed.aroundTopText, 500);
5876
+ const aroundBottomText = normalizeBookmarkField(parsed.aroundBottomText, 500);
5877
+ const latestSeqAtCapture = normalizeBookmarkSeq(parsed.latestSeqAtCapture);
5878
+ const oldestSeqLoadedAtCapture = normalizeBookmarkSeq(parsed.oldestSeqLoadedAtCapture);
5879
+ const viewportYHint = normalizeBookmarkViewportHint(parsed.viewportYHint);
5880
+ const anchorHash = normalizeBookmarkHash(parsed.anchorHash)
5881
+ || stableBookmarkHash([aroundTopText, anchorText, aroundBottomText]);
5882
+ const result = (0, web_terminal_js_1.addWebTerminalBookmark)(resolved.record.id, {
5883
+ ...(label ? { label } : {}),
5884
+ anchorText,
5885
+ previewText,
5886
+ viewportYHint,
5887
+ ...(aroundTopText ? { aroundTopText } : {}),
5888
+ ...(aroundBottomText ? { aroundBottomText } : {}),
5889
+ latestSeqAtCapture,
5890
+ oldestSeqLoadedAtCapture,
5891
+ ...(anchorHash ? { anchorHash } : {}),
5892
+ });
5893
+ if (!result.ok) {
5894
+ json(res, { ok: false, error: result.error, code: result.code }, 404);
5895
+ return;
5896
+ }
5897
+ json(res, {
5898
+ ok: true,
5899
+ id: resolved.record.id,
5900
+ bookmark: serializeWebTerminalBookmark(result.bookmark),
5901
+ });
5902
+ }
5903
+ catch (err) {
5904
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5905
+ }
5906
+ }
5907
+ async function handleDeleteWebTerminalBookmarks(reqUrl, res) {
5908
+ if (!isTerminalBookmarksFeatureEnabled()) {
5909
+ json(res, { ok: false, error: 'Terminal bookmarks are temporarily disabled.' }, 404);
5910
+ return;
5911
+ }
5912
+ const id = String(reqUrl.searchParams.get('id') || '').trim();
5913
+ const bookmarkId = String(reqUrl.searchParams.get('bookmarkId') || '').trim();
5914
+ if (!bookmarkId) {
5915
+ json(res, { ok: false, error: 'bookmarkId is required' }, 400);
5916
+ return;
5917
+ }
5918
+ const resolved = resolveLocalWebTerminalRecord(id);
5919
+ if (!resolved.ok) {
5920
+ json(res, { ok: false, error: resolved.error }, resolved.status);
5921
+ return;
5922
+ }
5923
+ const result = (0, web_terminal_js_1.deleteWebTerminalBookmark)(resolved.record.id, bookmarkId);
5924
+ if (!result.ok) {
5925
+ json(res, { ok: false, error: result.error, code: result.code }, 404);
5926
+ return;
5927
+ }
5928
+ json(res, { ok: true, id: resolved.record.id, bookmarkId });
5929
+ }
4582
5930
  async function handlePostWebTerminalRename(req, res) {
4583
5931
  try {
4584
5932
  const body = await readBody(req);
@@ -4605,6 +5953,37 @@ async function handlePostWebTerminalRename(req, res) {
4605
5953
  json(res, { ok: false, error: err?.message ?? String(err) }, 500);
4606
5954
  }
4607
5955
  }
5956
+ async function handlePostWebTerminalStar(req, res) {
5957
+ try {
5958
+ const body = await readBody(req);
5959
+ const parsed = JSON.parse(body || '{}');
5960
+ const id = String(parsed.id || '').trim();
5961
+ const starred = parsed.starred === true;
5962
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
5963
+ json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
5964
+ return;
5965
+ }
5966
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
5967
+ if (!record) {
5968
+ json(res, { ok: false, error: 'Terminal session not found' }, 404);
5969
+ return;
5970
+ }
5971
+ if (record.node !== (0, os_1.hostname)()) {
5972
+ json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
5973
+ return;
5974
+ }
5975
+ const result = (0, web_terminal_js_1.setWebTerminalRecordStarred)(id, starred);
5976
+ if (!result.ok) {
5977
+ const status = result.code === 'not_found' ? 404 : 400;
5978
+ json(res, { ok: false, error: result.error, code: result.code }, status);
5979
+ return;
5980
+ }
5981
+ json(res, { ok: true, session: serializeWebTerminalSession(result.record) });
5982
+ }
5983
+ catch (err) {
5984
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
5985
+ }
5986
+ }
4608
5987
  async function handlePostWebTerminalStop(req, res) {
4609
5988
  try {
4610
5989
  const body = await readBody(req);
@@ -5043,6 +6422,103 @@ function handleGetDatasetStats(_req, res) {
5043
6422
  }
5044
6423
  json(res, { stats });
5045
6424
  }
6425
+ function handleGetDatasetPreview(reqUrl, res) {
6426
+ const name = String(reqUrl.searchParams.get('name') || '').trim();
6427
+ const maxRows = Math.min(Math.max(1, Number(reqUrl.searchParams.get('rows')) || 5), 20);
6428
+ if (!name) {
6429
+ json(res, { ok: false, error: 'Missing name parameter' }, 400);
6430
+ return;
6431
+ }
6432
+ const config = (0, config_js_1.loadConfig)();
6433
+ const ds = (config.datasets || []).find((d) => d.name && String(d.name).toLowerCase() === name.toLowerCase());
6434
+ if (!ds) {
6435
+ json(res, { ok: false, error: `Dataset "${name}" not found` }, 404);
6436
+ return;
6437
+ }
6438
+ const hostPath = ds.path
6439
+ ? String(ds.path).replace(/^~/, (0, os_1.homedir)())
6440
+ : '';
6441
+ if (!hostPath || !(0, fs_1.existsSync)(hostPath)) {
6442
+ json(res, { ok: false, error: 'Dataset path not found' }, 404);
6443
+ return;
6444
+ }
6445
+ let entries;
6446
+ try {
6447
+ entries = (0, fs_1.readdirSync)(hostPath);
6448
+ }
6449
+ catch {
6450
+ json(res, { ok: false, error: 'Cannot read dataset directory' }, 500);
6451
+ return;
6452
+ }
6453
+ // Find first CSV/TSV/TXT file
6454
+ const csvTsvFile = entries.find((f) => {
6455
+ const lower = f.toLowerCase();
6456
+ return lower.endsWith('.csv') || lower.endsWith('.tsv') || lower.endsWith('.txt');
6457
+ });
6458
+ if (!csvTsvFile) {
6459
+ json(res, { ok: false, error: 'No CSV/TSV file found in dataset' }, 404);
6460
+ return;
6461
+ }
6462
+ const filePath = (0, path_1.join)(hostPath, csvTsvFile);
6463
+ let fileStat;
6464
+ try {
6465
+ fileStat = (0, fs_1.statSync)(filePath);
6466
+ }
6467
+ catch {
6468
+ json(res, { ok: false, error: 'Cannot stat file' }, 500);
6469
+ return;
6470
+ }
6471
+ if (!fileStat.isFile()) {
6472
+ json(res, { ok: false, error: 'Not a file' }, 400);
6473
+ return;
6474
+ }
6475
+ // Read first chunk (limit to 64KB)
6476
+ const MAX_PREVIEW_BYTES = 65536;
6477
+ let fd;
6478
+ try {
6479
+ fd = (0, fs_1.openSync)(filePath, 'r');
6480
+ }
6481
+ catch {
6482
+ json(res, { ok: false, error: 'Cannot open file' }, 500);
6483
+ return;
6484
+ }
6485
+ const readSize = Math.min(fileStat.size, MAX_PREVIEW_BYTES);
6486
+ const buffer = Buffer.alloc(readSize);
6487
+ let bytesRead = 0;
6488
+ try {
6489
+ bytesRead = (0, fs_1.readSync)(fd, buffer, 0, readSize, 0);
6490
+ }
6491
+ catch {
6492
+ json(res, { ok: false, error: 'Cannot read file' }, 500);
6493
+ return;
6494
+ }
6495
+ finally {
6496
+ try {
6497
+ (0, fs_1.closeSync)(fd);
6498
+ }
6499
+ catch { /* noop */ }
6500
+ }
6501
+ const content = buffer.subarray(0, bytesRead).toString('utf-8');
6502
+ const lines = content.split(/\r?\n/).filter((l) => l.trim());
6503
+ if (lines.length === 0) {
6504
+ json(res, { ok: false, error: 'File is empty' }, 400);
6505
+ return;
6506
+ }
6507
+ const delimiter = csvTsvFile.toLowerCase().endsWith('.tsv') ? '\t' : ',';
6508
+ const columns = lines[0].split(delimiter).map((c) => c.trim().replace(/^["']|["']$/g, ''));
6509
+ const rows = [];
6510
+ for (let i = 1; i < lines.length && rows.length < maxRows; i++) {
6511
+ const cells = lines[i].split(delimiter).map((c) => c.trim().replace(/^["']|["']$/g, ''));
6512
+ rows.push(cells);
6513
+ }
6514
+ json(res, {
6515
+ ok: true,
6516
+ columns,
6517
+ rows,
6518
+ file: csvTsvFile,
6519
+ total_rows: bytesRead < fileStat.size ? null : (lines.length - 1),
6520
+ });
6521
+ }
5046
6522
  async function handlePostDatasetScan(req, res) {
5047
6523
  try {
5048
6524
  const body = await readBody(req);
@@ -5052,20 +6528,7 @@ async function handlePostDatasetScan(req, res) {
5052
6528
  return;
5053
6529
  }
5054
6530
  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
- }
6531
+ const obj = (0, config_js_1.readRawConfigFile)(configPath);
5069
6532
  const datasets = (obj.datasets || []);
5070
6533
  const idx = datasets.findIndex((d) => d?.name && String(d.name).toLowerCase() === name.toLowerCase());
5071
6534
  if (idx < 0) {
@@ -5087,8 +6550,7 @@ async function handlePostDatasetScan(req, res) {
5087
6550
  };
5088
6551
  ds.stats = statsObj;
5089
6552
  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);
6553
+ (0, config_js_1.writeRawConfigFile)(obj, configPath);
5092
6554
  json(res, { ok: true, stats: statsObj });
5093
6555
  }
5094
6556
  catch (err) {
@@ -5120,20 +6582,7 @@ async function handlePostDatasetExampleInstall(req, res) {
5120
6582
  const datasetMode = parsed.mode === 'rw' ? 'rw' : 'ro';
5121
6583
  const sourceUrl = resolveIrisSampleSourceUrl();
5122
6584
  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
- }
6585
+ const obj = (0, config_js_1.readRawConfigFile)(configPath);
5137
6586
  const datasets = Array.isArray(obj.datasets) ? obj.datasets : [];
5138
6587
  const byNameConflict = datasets.find((d) => {
5139
6588
  const n = typeof d.name === 'string' ? d.name : '';
@@ -5201,8 +6650,7 @@ async function handlePostDatasetExampleInstall(req, res) {
5201
6650
  };
5202
6651
  datasets.push(entry);
5203
6652
  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);
6653
+ (0, config_js_1.writeRawConfigFile)(obj, configPath);
5206
6654
  json(res, {
5207
6655
  ok: true,
5208
6656
  dataset: entry,
@@ -5453,16 +6901,13 @@ function collectMcpState() {
5453
6901
  const slurmPluginEnabled = isPluginEnabledInConfig(config, 'slurm');
5454
6902
  const slurmConfigured = slurmPluginEnabled && config.slurm.enabled && config.slurm.mcp_server;
5455
6903
  const clusterConfigured = slurmPluginEnabled && config.slurm.enabled && config.slurm.mcp_server;
5456
- const datasetsConfigured = true;
5457
- const resultsConfigured = true;
6904
+ const datasetsConfigured = isPluginEnabledInConfig(config, 'datasets');
5458
6905
  const slurmEntry = registeredServers['labgate-slurm'];
5459
6906
  const clusterEntry = registeredServers['labgate-cluster'];
5460
6907
  const datasetsEntry = registeredServers['labgate-datasets'];
5461
- const resultsEntry = registeredServers['labgate-results'];
5462
6908
  const slurmState = inferServerState('labgate-slurm', slurmConfigured, slurmEntry, sandboxHome);
5463
6909
  const clusterState = inferServerState('labgate-cluster', clusterConfigured, clusterEntry, sandboxHome);
5464
6910
  const datasetsState = inferServerState('labgate-datasets', datasetsConfigured, datasetsEntry, sandboxHome);
5465
- const resultsState = inferServerState('labgate-results', resultsConfigured, resultsEntry, sandboxHome);
5466
6911
  const servers = [
5467
6912
  {
5468
6913
  id: 'labgate-slurm',
@@ -5536,35 +6981,6 @@ function collectMcpState() {
5536
6981
  { name: 'unregister_dataset', title: 'Unregister Dataset', description: 'Remove a dataset from the config' },
5537
6982
  ],
5538
6983
  },
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
6984
  ];
5569
6985
  return {
5570
6986
  mcpConfigPath,
@@ -7652,6 +9068,20 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7652
9068
  return;
7653
9069
  }
7654
9070
  }
9071
+ if (pathname.startsWith('/api/dataset')) {
9072
+ const config = (0, config_js_1.loadConfig)();
9073
+ if (!isPluginEnabledInConfig(config, 'datasets')) {
9074
+ json(res, { ok: false, error: 'Datasets plugin is disabled.' }, 403);
9075
+ return;
9076
+ }
9077
+ }
9078
+ if (pathname === '/api/results' || pathname.startsWith('/api/results/')) {
9079
+ const config = (0, config_js_1.loadConfig)();
9080
+ if (!isPluginEnabledInConfig(config, 'results')) {
9081
+ json(res, { ok: false, error: 'Results plugin is disabled.' }, 403);
9082
+ return;
9083
+ }
9084
+ }
7655
9085
  if (pathname === '/' && method === 'GET') {
7656
9086
  serveHTML(res);
7657
9087
  }
@@ -7743,6 +9173,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7743
9173
  else if (pathname === '/api/dataset-stats' && method === 'GET') {
7744
9174
  handleGetDatasetStats(req, res);
7745
9175
  }
9176
+ else if (pathname === '/api/dataset-preview' && method === 'GET') {
9177
+ handleGetDatasetPreview(reqUrl, res);
9178
+ }
7746
9179
  else if (pathname === '/api/dataset-scan' && method === 'POST') {
7747
9180
  await handlePostDatasetScan(req, res);
7748
9181
  }
@@ -7767,6 +9200,15 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7767
9200
  else if (pathname === '/api/claude/auth' && method === 'GET') {
7768
9201
  handleGetClaudeAuthStatus(req, res);
7769
9202
  }
9203
+ else if (pathname === '/api/claude/auth/login/start' && method === 'POST') {
9204
+ await handlePostClaudeAuthLoginStart(req, res);
9205
+ }
9206
+ else if (pathname === '/api/claude/auth/login/code' && method === 'POST') {
9207
+ await handlePostClaudeAuthLoginCode(req, res);
9208
+ }
9209
+ else if (pathname === '/api/claude/oauth-url' && method === 'GET') {
9210
+ handleGetClaudeOauthUrl(reqUrl, res);
9211
+ }
7770
9212
  else if (pathname === '/api/results' && method === 'GET') {
7771
9213
  handleGetResults(reqUrl, res);
7772
9214
  }
@@ -7788,6 +9230,15 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7788
9230
  else if (pathname === '/api/terminal/history' && method === 'GET') {
7789
9231
  await handleGetWebTerminalHistory(reqUrl, res);
7790
9232
  }
9233
+ else if (pathname === '/api/terminal/bookmarks' && method === 'GET') {
9234
+ await handleGetWebTerminalBookmarks(reqUrl, res);
9235
+ }
9236
+ else if (pathname === '/api/terminal/bookmarks' && method === 'POST') {
9237
+ await handlePostWebTerminalBookmarks(req, res);
9238
+ }
9239
+ else if (pathname === '/api/terminal/bookmarks' && method === 'DELETE') {
9240
+ await handleDeleteWebTerminalBookmarks(reqUrl, res);
9241
+ }
7791
9242
  else if (pathname === '/api/terminal/init' && method === 'GET') {
7792
9243
  await handleGetWebTerminalInit(reqUrl, res);
7793
9244
  }
@@ -7803,6 +9254,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
7803
9254
  else if (pathname === '/api/terminal/rename' && method === 'POST') {
7804
9255
  await handlePostWebTerminalRename(req, res);
7805
9256
  }
9257
+ else if (pathname === '/api/terminal/star' && method === 'POST') {
9258
+ await handlePostWebTerminalStar(req, res);
9259
+ }
7806
9260
  else if (pathname === '/api/terminal/stop' && method === 'POST') {
7807
9261
  await handlePostWebTerminalStop(req, res);
7808
9262
  }
@@ -8015,7 +9469,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
8015
9469
  void (async () => {
8016
9470
  if (prewarmImageOnStartup) {
8017
9471
  try {
8018
- const cfg = (0, config_js_1.loadConfig)();
9472
+ const cfg = (0, config_js_1.loadEffectiveConfig)().config;
8019
9473
  const runtimeReady = await prepareRuntimeForWebTerminal(cfg.runtime);
8020
9474
  if (!runtimeReady.ok) {
8021
9475
  const firstLine = String(runtimeReady.error || 'Container runtime unavailable.')
@@ -8048,10 +9502,10 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
8048
9502
  }
8049
9503
  }
8050
9504
  else {
8051
- imageExists = (0, fs_1.existsSync)((0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image)));
9505
+ imageExists = (0, container_js_1.isUsableApptainerSif)('apptainer', (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image)));
8052
9506
  }
8053
9507
  if (!imageExists) {
8054
- log.step(`No cached image found for ${image}. Preparing it before opening UI...`);
9508
+ log.step(`No usable cached image found for ${image}. Preparing it before opening UI...`);
8055
9509
  await ensureWebTerminalImageReady(runtime, image);
8056
9510
  log.success(`Prepared image ${image}.`);
8057
9511
  }