labgate 0.5.31 → 0.5.33

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 (73) hide show
  1. package/README.md +50 -2
  2. package/dist/cli.js +533 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/config.d.ts +11 -0
  5. package/dist/lib/config.js +45 -4
  6. package/dist/lib/config.js.map +1 -1
  7. package/dist/lib/container.d.ts +3 -3
  8. package/dist/lib/container.js +144 -12
  9. package/dist/lib/container.js.map +1 -1
  10. package/dist/lib/display-mcp.d.ts +10 -0
  11. package/dist/lib/display-mcp.js +160 -0
  12. package/dist/lib/display-mcp.js.map +1 -0
  13. package/dist/lib/display-store.d.ts +24 -0
  14. package/dist/lib/display-store.js +150 -0
  15. package/dist/lib/display-store.js.map +1 -0
  16. package/dist/lib/explorer-autopilot.d.ts +16 -0
  17. package/dist/lib/explorer-autopilot.js +573 -0
  18. package/dist/lib/explorer-autopilot.js.map +1 -0
  19. package/dist/lib/explorer-claude.d.ts +16 -0
  20. package/dist/lib/explorer-claude.js +361 -0
  21. package/dist/lib/explorer-claude.js.map +1 -0
  22. package/dist/lib/explorer-compare.d.ts +9 -0
  23. package/dist/lib/explorer-compare.js +190 -0
  24. package/dist/lib/explorer-compare.js.map +1 -0
  25. package/dist/lib/explorer-eval.d.ts +23 -0
  26. package/dist/lib/explorer-eval.js +161 -0
  27. package/dist/lib/explorer-eval.js.map +1 -0
  28. package/dist/lib/explorer-gc.d.ts +11 -0
  29. package/dist/lib/explorer-gc.js +304 -0
  30. package/dist/lib/explorer-gc.js.map +1 -0
  31. package/dist/lib/explorer-git.d.ts +14 -0
  32. package/dist/lib/explorer-git.js +136 -0
  33. package/dist/lib/explorer-git.js.map +1 -0
  34. package/dist/lib/explorer-lock.d.ts +5 -0
  35. package/dist/lib/explorer-lock.js +100 -0
  36. package/dist/lib/explorer-lock.js.map +1 -0
  37. package/dist/lib/explorer-mcp.d.ts +11 -0
  38. package/dist/lib/explorer-mcp.js +611 -0
  39. package/dist/lib/explorer-mcp.js.map +1 -0
  40. package/dist/lib/explorer-retention.d.ts +4 -0
  41. package/dist/lib/explorer-retention.js +58 -0
  42. package/dist/lib/explorer-retention.js.map +1 -0
  43. package/dist/lib/explorer-store.d.ts +77 -0
  44. package/dist/lib/explorer-store.js +950 -0
  45. package/dist/lib/explorer-store.js.map +1 -0
  46. package/dist/lib/explorer-types.d.ts +161 -0
  47. package/dist/lib/explorer-types.js +3 -0
  48. package/dist/lib/explorer-types.js.map +1 -0
  49. package/dist/lib/explorer.d.ts +31 -0
  50. package/dist/lib/explorer.js +247 -0
  51. package/dist/lib/explorer.js.map +1 -0
  52. package/dist/lib/results-store.js +37 -3
  53. package/dist/lib/results-store.js.map +1 -1
  54. package/dist/lib/test/integration-harness.js +1 -1
  55. package/dist/lib/test/integration-harness.js.map +1 -1
  56. package/dist/lib/ui.html +5115 -2052
  57. package/dist/lib/ui.js +906 -39
  58. package/dist/lib/ui.js.map +1 -1
  59. package/dist/lib/web-terminal.js +4 -3
  60. package/dist/lib/web-terminal.js.map +1 -1
  61. package/dist/mcp-bundles/dataset-mcp.bundle.mjs +0 -8
  62. package/dist/mcp-bundles/display-mcp.bundle.mjs +30209 -0
  63. package/dist/mcp-bundles/explorer-mcp.bundle.mjs +40036 -0
  64. package/dist/mcp-bundles/results-mcp.bundle.mjs +30 -4
  65. package/package.json +3 -2
  66. package/templates/tsp-lab/API_CONTRACT.md +20 -0
  67. package/templates/tsp-lab/EVAL.md +20 -0
  68. package/templates/tsp-lab/PROBLEM.md +18 -0
  69. package/templates/tsp-lab/data/generate_instances.py +51 -0
  70. package/templates/tsp-lab/data/instances.jsonl +12 -0
  71. package/templates/tsp-lab/eval.py +148 -0
  72. package/templates/tsp-lab/solver.py +88 -0
  73. package/templates/tsp-lab/stub-patches/enable_two_opt.patch +14 -0
package/dist/lib/ui.js CHANGED
@@ -50,9 +50,13 @@ const audit_js_1 = require("./audit.js");
50
50
  const slurm_db_js_1 = require("./slurm-db.js");
51
51
  const slurm_poller_js_1 = require("./slurm-poller.js");
52
52
  const results_store_js_1 = require("./results-store.js");
53
+ const display_store_js_1 = require("./display-store.js");
53
54
  const policy_js_1 = require("./policy.js");
54
55
  const license_js_1 = require("./license.js");
55
56
  const web_terminal_js_1 = require("./web-terminal.js");
57
+ const explorer_js_1 = require("./explorer.js");
58
+ const explorer_eval_js_1 = require("./explorer-eval.js");
59
+ const explorer_store_js_1 = require("./explorer-store.js");
56
60
  const log = __importStar(require("./log.js"));
57
61
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
58
62
  const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
@@ -72,6 +76,9 @@ const IRIS_SAMPLE_DATASET_NAME = 'flowers-iris';
72
76
  const IRIS_SAMPLE_SOURCE_URL = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv';
73
77
  const PODMAN_SETUP_TIMEOUT_MS = 30 * 60 * 1000;
74
78
  const PODMAN_SETUP_MAX_BUFFER = 16 * 1024 * 1024;
79
+ const EXPLORER_TSP_TEMPLATE_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'templates', 'tsp-lab');
80
+ const EXPLORER_TSP_TEMPLATE_SOURCE_REPO = (0, path_1.join)((0, config_js_1.getExplorerRootDir)(), 'templates', 'tsp-lab-source');
81
+ const EXPLORER_ARTIFACT_READ_MAX_BYTES = 2 * 1024 * 1024;
75
82
  const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
76
83
  '\n' +
77
84
  'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
@@ -88,6 +95,7 @@ function resolveIrisSampleSourceUrl() {
88
95
  let slurmDB = null;
89
96
  let slurmPoller = null;
90
97
  let resultsStore = null;
98
+ let displayStore = null;
91
99
  const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
92
100
  const webTerminalBridges = new Map();
93
101
  const WEB_TERMINAL_INIT_RETENTION_MS = 60 * 60 * 1000;
@@ -116,6 +124,12 @@ function getResultsStore() {
116
124
  }
117
125
  return resultsStore;
118
126
  }
127
+ function getDisplayStore() {
128
+ if (!displayStore) {
129
+ displayStore = new display_store_js_1.DisplayStore((0, config_js_1.getDisplayDbPath)());
130
+ }
131
+ return displayStore;
132
+ }
119
133
  function hasCommandInPath(command) {
120
134
  const pathValue = (process.env.PATH || '').trim();
121
135
  if (!pathValue)
@@ -1017,6 +1031,13 @@ function readRecordString(record, key) {
1017
1031
  const value = record[key];
1018
1032
  return typeof value === 'string' ? value : '';
1019
1033
  }
1034
+ function normalizeToolUseId(value) {
1035
+ if (typeof value === 'string')
1036
+ return value.trim();
1037
+ if (typeof value === 'number' && Number.isFinite(value))
1038
+ return String(value);
1039
+ return '';
1040
+ }
1020
1041
  function collectClaudeTextFromContent(content) {
1021
1042
  if (!Array.isArray(content))
1022
1043
  return '';
@@ -1069,6 +1090,15 @@ function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSession
1069
1090
  const sandboxHome = (0, config_js_1.getSandboxHome)();
1070
1091
  const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
1071
1092
  const resume = resumeSessionId.trim();
1093
+ // Ensure display.json exists before bind-mounting it
1094
+ const displayDbPath = (0, config_js_1.getDisplayDbPath)();
1095
+ if (!(0, fs_1.existsSync)(displayDbPath)) {
1096
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(displayDbPath));
1097
+ (0, fs_1.writeFileSync)(displayDbPath, JSON.stringify({ version: 1, events: [] }, null, 2) + '\n', {
1098
+ encoding: 'utf-8',
1099
+ mode: config_js_1.PRIVATE_FILE_MODE,
1100
+ });
1101
+ }
1072
1102
  return [
1073
1103
  'exec',
1074
1104
  '--containall',
@@ -1088,6 +1118,7 @@ function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSession
1088
1118
  return ['--bind', bindSpec];
1089
1119
  }),
1090
1120
  ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1121
+ '--bind', `${(0, config_js_1.getDisplayDbPath)()}:/labgate-config/display.json`,
1091
1122
  '--env', 'HOME=/home/sandbox',
1092
1123
  '--env', 'ANTHROPIC_API_KEY=',
1093
1124
  sifPath,
@@ -1164,27 +1195,32 @@ function closeWebTerminalBridgeClients(bridge, code = 4001, reason = 'labgate-br
1164
1195
  }
1165
1196
  async function ensureWebTerminalBridge(record) {
1166
1197
  const existing = webTerminalBridges.get(record.id);
1167
- if (existing && existing.pty)
1168
- return existing;
1169
- const ptyModule = await loadNodePtyModule();
1170
- if (!ptyModule) {
1171
- return null;
1172
- }
1173
1198
  let tmuxBin = 'tmux';
1174
1199
  try {
1175
1200
  tmuxBin = await (0, web_terminal_js_1.getTmuxBinary)();
1176
1201
  }
1177
1202
  catch (err) {
1203
+ if (existing && existing.pty) {
1204
+ log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
1205
+ return existing;
1206
+ }
1178
1207
  log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
1179
1208
  return null;
1180
1209
  }
1181
1210
  try {
1182
- // Keep wheel scrolling intuitive for both new and existing sessions.
1183
- await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, 'mouse', 'on'], { timeout: 10_000 });
1211
+ // Keep web terminal copy/selection behavior reliable by letting xterm own
1212
+ // mouse selection instead of tmux copy-mode selection.
1213
+ await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, 'mouse', 'off'], { timeout: 10_000 });
1184
1214
  }
1185
1215
  catch {
1186
1216
  // Best effort only; attach should still proceed.
1187
1217
  }
1218
+ if (existing && existing.pty)
1219
+ return existing;
1220
+ const ptyModule = await loadNodePtyModule();
1221
+ if (!ptyModule) {
1222
+ return null;
1223
+ }
1188
1224
  const env = {};
1189
1225
  for (const [k, v] of Object.entries(process.env)) {
1190
1226
  if (v !== undefined)
@@ -1345,7 +1381,6 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1345
1381
  return () => { };
1346
1382
  }
1347
1383
  const args = buildClaudeHeadlessApptainerArgs(config, record.workdir, trimmedPrompt, resumeSessionId);
1348
- send({ type: 'status', stage: 'run', message: 'Running Claude in headless mode...' });
1349
1384
  const child = (0, child_process_1.spawn)('apptainer', args, {
1350
1385
  cwd: record.workdir,
1351
1386
  env: process.env,
@@ -1356,6 +1391,7 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1356
1391
  let latestClaudeSessionId = resumeSessionId.trim();
1357
1392
  let emittedAssistantText = '';
1358
1393
  let doneSent = false;
1394
+ let syntheticToolUseSeq = 0;
1359
1395
  const sendDone = (exitCode) => {
1360
1396
  if (doneSent)
1361
1397
  return;
@@ -1406,6 +1442,53 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1406
1442
  send({ type: 'delta', text: delta });
1407
1443
  }
1408
1444
  }
1445
+ // Forward tool_use events from assistant messages
1446
+ const eventType = readRecordString(event, 'type').trim().toLowerCase();
1447
+ if (eventType === 'assistant') {
1448
+ const msgContent = event.message?.content;
1449
+ if (Array.isArray(msgContent)) {
1450
+ for (const block of msgContent) {
1451
+ if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_use') {
1452
+ const toolBlock = block;
1453
+ const toolName = String(toolBlock.name || 'tool');
1454
+ const detail = extractToolDetailFromToolUseBlock(toolBlock);
1455
+ const toolUseId = normalizeToolUseId(toolBlock.id) || `tool-${Date.now().toString(36)}-${(++syntheticToolUseSeq).toString(36)}`;
1456
+ // Intercept display_widget calls and forward rich content payload
1457
+ if (toolName === 'display_widget') {
1458
+ const input = toolBlock.input;
1459
+ if (input && typeof input.widget === 'string') {
1460
+ send({
1461
+ type: 'rich_content',
1462
+ widget: String(input.widget),
1463
+ title: input.title ? String(input.title) : undefined,
1464
+ data: (input.data && typeof input.data === 'object') ? input.data : {},
1465
+ id: toolUseId,
1466
+ });
1467
+ }
1468
+ }
1469
+ // Always also send the normal tool_use card
1470
+ send({ type: 'tool_use', tool_use_id: toolUseId, name: toolName, detail });
1471
+ }
1472
+ }
1473
+ }
1474
+ }
1475
+ // Forward tool_result events (indicates tool execution completed)
1476
+ if (eventType === 'user') {
1477
+ const msgContent = event.message?.content;
1478
+ if (Array.isArray(msgContent)) {
1479
+ for (const block of msgContent) {
1480
+ if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_result') {
1481
+ const resultBlock = block;
1482
+ const toolUseId = normalizeToolUseId(resultBlock.tool_use_id);
1483
+ send({
1484
+ type: 'tool_result',
1485
+ tool_use_id: toolUseId || undefined,
1486
+ is_error: !!resultBlock.is_error,
1487
+ });
1488
+ }
1489
+ }
1490
+ }
1491
+ }
1409
1492
  if (isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
1410
1493
  send({
1411
1494
  type: 'auth_required',
@@ -2248,6 +2331,39 @@ function findProjectJsonlFiles(agent) {
2248
2331
  /**
2249
2332
  * Extract a human-readable detail string from a JSONL entry's tool_use blocks.
2250
2333
  */
2334
+ function extractToolDetailFromToolUseBlock(block) {
2335
+ const name = String(block.name || '');
2336
+ const inputRaw = block.input;
2337
+ const input = inputRaw && typeof inputRaw === 'object' && !Array.isArray(inputRaw)
2338
+ ? inputRaw
2339
+ : {};
2340
+ if (name === 'Bash' || name === 'bash') {
2341
+ const cmd = String(input.command || '').slice(0, 60);
2342
+ return cmd ? `Ran \`${cmd}\`` : 'Running Bash';
2343
+ }
2344
+ if (name === 'Edit' || name === 'edit') {
2345
+ const file = String(input.file_path || '').split('/').pop() || '';
2346
+ return file ? `Edited ${file}` : 'Editing a file';
2347
+ }
2348
+ if (name === 'Read' || name === 'read') {
2349
+ const file = String(input.file_path || '').split('/').pop() || '';
2350
+ return file ? `Read ${file}` : 'Reading a file';
2351
+ }
2352
+ if (name === 'Write' || name === 'write') {
2353
+ const file = String(input.file_path || '').split('/').pop() || '';
2354
+ return file ? `Wrote ${file}` : 'Writing a file';
2355
+ }
2356
+ if (name === 'Grep' || name === 'grep') {
2357
+ return `Searching for "${String(input.pattern || '').slice(0, 40)}"`;
2358
+ }
2359
+ if (name === 'Glob' || name === 'glob') {
2360
+ return `Finding files: ${String(input.pattern || '').slice(0, 40)}`;
2361
+ }
2362
+ if (name === 'Task' || name === 'task') {
2363
+ return 'Spawned subagent';
2364
+ }
2365
+ return `Using ${name}`;
2366
+ }
2251
2367
  function extractToolDetail(entry) {
2252
2368
  if (!entry.message?.content)
2253
2369
  return '';
@@ -2255,35 +2371,8 @@ function extractToolDetail(entry) {
2255
2371
  if (!Array.isArray(content))
2256
2372
  return '';
2257
2373
  for (const block of content) {
2258
- if (block.type === 'tool_use') {
2259
- const name = block.name || '';
2260
- const input = block.input || {};
2261
- if (name === 'Bash' || name === 'bash') {
2262
- const cmd = (input.command || '').slice(0, 60);
2263
- return cmd ? `Ran \`${cmd}\`` : `Running Bash`;
2264
- }
2265
- if (name === 'Edit' || name === 'edit') {
2266
- const file = (input.file_path || '').split('/').pop() || '';
2267
- return file ? `Edited ${file}` : 'Editing a file';
2268
- }
2269
- if (name === 'Read' || name === 'read') {
2270
- const file = (input.file_path || '').split('/').pop() || '';
2271
- return file ? `Read ${file}` : 'Reading a file';
2272
- }
2273
- if (name === 'Write' || name === 'write') {
2274
- const file = (input.file_path || '').split('/').pop() || '';
2275
- return file ? `Wrote ${file}` : 'Writing a file';
2276
- }
2277
- if (name === 'Grep' || name === 'grep') {
2278
- return `Searching for "${(input.pattern || '').slice(0, 40)}"`;
2279
- }
2280
- if (name === 'Glob' || name === 'glob') {
2281
- return `Finding files: ${(input.pattern || '').slice(0, 40)}`;
2282
- }
2283
- if (name === 'Task' || name === 'task') {
2284
- return `Spawned subagent`;
2285
- }
2286
- return `Using ${name}`;
2374
+ if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_use') {
2375
+ return extractToolDetailFromToolUseBlock(block);
2287
2376
  }
2288
2377
  }
2289
2378
  return '';
@@ -3588,6 +3677,8 @@ function mapContainerPathToHost(path, sandboxHome) {
3588
3677
  return (0, config_js_1.getSlurmDbPath)();
3589
3678
  if (path === '/labgate-config/results.json')
3590
3679
  return (0, config_js_1.getResultsDbPath)();
3680
+ if (path === '/labgate-config/display.json')
3681
+ return (0, config_js_1.getDisplayDbPath)();
3591
3682
  return path;
3592
3683
  }
3593
3684
  function readMcpConfigData() {
@@ -4424,6 +4515,88 @@ function startResultsWatcher() {
4424
4515
  resultsWatcher = null;
4425
4516
  }
4426
4517
  }
4518
+ // ── Display (widgets) file watcher ──
4519
+ const DISPLAY_WATCH_DEBOUNCE_MS = 120;
4520
+ let lastDisplaySignature = getDisplayFileSignature();
4521
+ let displayWatcher = null;
4522
+ let displayWatchDebounce = null;
4523
+ function getDisplayFileSignature() {
4524
+ const displayPath = (0, config_js_1.getDisplayDbPath)();
4525
+ try {
4526
+ if (!(0, fs_1.existsSync)(displayPath))
4527
+ return 'missing';
4528
+ const st = (0, fs_1.statSync)(displayPath);
4529
+ return `${st.size}:${Math.floor(st.mtimeMs)}`;
4530
+ }
4531
+ catch {
4532
+ return 'error';
4533
+ }
4534
+ }
4535
+ function maybeBroadcastWidgetsChanged() {
4536
+ const signature = getDisplayFileSignature();
4537
+ if (signature === lastDisplaySignature)
4538
+ return;
4539
+ if (sseClients.size === 0)
4540
+ return;
4541
+ lastDisplaySignature = signature;
4542
+ broadcastSSE('widgets_changed', {
4543
+ changed_at: new Date().toISOString(),
4544
+ signature,
4545
+ });
4546
+ }
4547
+ function scheduleDisplayChangeCheck(delayMs = DISPLAY_WATCH_DEBOUNCE_MS) {
4548
+ if (displayWatchDebounce) {
4549
+ clearTimeout(displayWatchDebounce);
4550
+ }
4551
+ displayWatchDebounce = setTimeout(() => {
4552
+ displayWatchDebounce = null;
4553
+ maybeBroadcastWidgetsChanged();
4554
+ }, delayMs);
4555
+ displayWatchDebounce.unref?.();
4556
+ }
4557
+ function stopDisplayWatcher() {
4558
+ if (displayWatchDebounce) {
4559
+ clearTimeout(displayWatchDebounce);
4560
+ displayWatchDebounce = null;
4561
+ }
4562
+ if (displayWatcher) {
4563
+ try {
4564
+ displayWatcher.close();
4565
+ }
4566
+ catch {
4567
+ // Best effort.
4568
+ }
4569
+ displayWatcher = null;
4570
+ }
4571
+ }
4572
+ function startDisplayWatcher() {
4573
+ if (displayWatcher)
4574
+ return;
4575
+ lastDisplaySignature = getDisplayFileSignature();
4576
+ const displayPath = (0, config_js_1.getDisplayDbPath)();
4577
+ const watchDir = (0, path_1.dirname)(displayPath);
4578
+ const watchFile = (0, path_1.basename)(displayPath);
4579
+ try {
4580
+ (0, config_js_1.ensurePrivateDir)(watchDir);
4581
+ }
4582
+ catch {
4583
+ // Best effort.
4584
+ }
4585
+ try {
4586
+ displayWatcher = (0, fs_1.watch)(watchDir, (_eventType, filename) => {
4587
+ const changed = filename ? String(filename) : '';
4588
+ if (changed && changed !== watchFile)
4589
+ return;
4590
+ scheduleDisplayChangeCheck();
4591
+ });
4592
+ displayWatcher.on('error', () => {
4593
+ stopDisplayWatcher();
4594
+ });
4595
+ }
4596
+ catch {
4597
+ displayWatcher = null;
4598
+ }
4599
+ }
4427
4600
  function handleSSE(_req, res) {
4428
4601
  res.writeHead(200, {
4429
4602
  'Content-Type': 'text/event-stream',
@@ -4452,6 +4625,7 @@ function startSSEBroadcast() {
4452
4625
  if (sseInterval)
4453
4626
  return;
4454
4627
  startResultsWatcher();
4628
+ startDisplayWatcher();
4455
4629
  sseInterval = setInterval(async () => {
4456
4630
  if (sseClients.size === 0)
4457
4631
  return;
@@ -4727,6 +4901,648 @@ function handleGetAdminLicense(_req, res) {
4727
4901
  const status = (0, license_js_1.validateLicense)();
4728
4902
  json(res, { ok: true, license: status });
4729
4903
  }
4904
+ // ── Display file endpoint ────────────────────────────────
4905
+ // Serves files from the container filesystem to the browser for display widgets.
4906
+ // Maps container paths to host paths using the same mount logic as the session.
4907
+ function resolveDisplayFilePath(containerPath) {
4908
+ const config = (0, config_js_1.loadConfig)();
4909
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
4910
+ // /home/sandbox/... → sandbox home
4911
+ if (containerPath.startsWith('/home/sandbox/')) {
4912
+ return (0, path_1.join)(sandboxHome, containerPath.slice('/home/sandbox/'.length));
4913
+ }
4914
+ // /datasets/<name>/... → dataset host path
4915
+ const datasetMatch = containerPath.match(/^\/datasets\/([^/]+)\/(.+)$/);
4916
+ if (datasetMatch) {
4917
+ const [, dsName, rest] = datasetMatch;
4918
+ const ds = (config.datasets || []).find((d) => d.name === dsName);
4919
+ if (ds) {
4920
+ const resolved = ds.path.replace(/^~/, (0, os_1.homedir)());
4921
+ return (0, path_1.join)(resolved, rest);
4922
+ }
4923
+ }
4924
+ // /work/... → workdir from active sessions
4925
+ if (containerPath.startsWith('/work/')) {
4926
+ const rest = containerPath.slice('/work/'.length);
4927
+ // Check active session workdirs
4928
+ try {
4929
+ const sessionDir = (0, config_js_1.getSessionsDir)();
4930
+ if ((0, fs_1.existsSync)(sessionDir)) {
4931
+ const files = (0, fs_1.readdirSync)(sessionDir).filter((f) => f.endsWith('.json'));
4932
+ for (const f of files) {
4933
+ try {
4934
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(sessionDir, f), 'utf-8'));
4935
+ if (data.workdir) {
4936
+ const candidate = (0, path_1.join)(data.workdir, rest);
4937
+ if ((0, fs_1.existsSync)(candidate))
4938
+ return candidate;
4939
+ }
4940
+ }
4941
+ catch { /* skip */ }
4942
+ }
4943
+ }
4944
+ }
4945
+ catch { /* skip */ }
4946
+ }
4947
+ // /mnt/<basename>/... → extra_paths
4948
+ const mntMatch = containerPath.match(/^\/mnt\/([^/]+)\/(.+)$/);
4949
+ if (mntMatch) {
4950
+ const [, mountBase, rest] = mntMatch;
4951
+ const ep = config.filesystem.extra_paths.find((p) => {
4952
+ const resolved = p.path.replace(/^~/, (0, os_1.homedir)());
4953
+ return (0, path_1.basename)(resolved) === mountBase;
4954
+ });
4955
+ if (ep) {
4956
+ const resolved = ep.path.replace(/^~/, (0, os_1.homedir)());
4957
+ return (0, path_1.join)(resolved, rest);
4958
+ }
4959
+ }
4960
+ return null;
4961
+ }
4962
+ function getContentTypeForFile(filePath) {
4963
+ const ext = filePath.split('.').pop()?.toLowerCase() || '';
4964
+ const mimeMap = {
4965
+ png: 'image/png',
4966
+ jpg: 'image/jpeg',
4967
+ jpeg: 'image/jpeg',
4968
+ gif: 'image/gif',
4969
+ svg: 'image/svg+xml',
4970
+ webp: 'image/webp',
4971
+ pdf: 'application/pdf',
4972
+ csv: 'text/csv',
4973
+ tsv: 'text/tab-separated-values',
4974
+ txt: 'text/plain',
4975
+ json: 'application/json',
4976
+ pdb: 'chemical/x-pdb',
4977
+ cif: 'chemical/x-cif',
4978
+ mmcif: 'chemical/x-mmcif',
4979
+ fasta: 'text/plain',
4980
+ fa: 'text/plain',
4981
+ fastq: 'text/plain',
4982
+ fq: 'text/plain',
4983
+ html: 'text/html',
4984
+ xml: 'application/xml',
4985
+ };
4986
+ return mimeMap[ext] || 'application/octet-stream';
4987
+ }
4988
+ function handleDisplayFile(reqUrl, res) {
4989
+ const containerPath = reqUrl.searchParams.get('path');
4990
+ if (!containerPath) {
4991
+ json(res, { ok: false, error: 'Missing path parameter' }, 400);
4992
+ return;
4993
+ }
4994
+ // Prevent directory traversal
4995
+ if (containerPath.includes('..') || containerPath.includes('\0')) {
4996
+ json(res, { ok: false, error: 'Invalid path' }, 400);
4997
+ return;
4998
+ }
4999
+ const hostPath = resolveDisplayFilePath(containerPath);
5000
+ if (!hostPath) {
5001
+ json(res, { ok: false, error: 'Path not within any allowed mount' }, 404);
5002
+ return;
5003
+ }
5004
+ // Verify the resolved path doesn't escape via symlinks
5005
+ let realPath;
5006
+ try {
5007
+ realPath = (0, fs_1.realpathSync)(hostPath);
5008
+ }
5009
+ catch {
5010
+ json(res, { ok: false, error: 'File not found' }, 404);
5011
+ return;
5012
+ }
5013
+ try {
5014
+ const stat = (0, fs_1.statSync)(realPath);
5015
+ if (!stat.isFile()) {
5016
+ json(res, { ok: false, error: 'Not a file' }, 400);
5017
+ return;
5018
+ }
5019
+ // Limit to 50MB
5020
+ if (stat.size > 50 * 1024 * 1024) {
5021
+ json(res, { ok: false, error: 'File too large (max 50MB)' }, 413);
5022
+ return;
5023
+ }
5024
+ const contentType = getContentTypeForFile(realPath);
5025
+ const data = (0, fs_1.readFileSync)(realPath);
5026
+ res.writeHead(200, {
5027
+ 'Content-Type': contentType,
5028
+ 'Content-Length': data.length,
5029
+ 'Cache-Control': 'no-cache',
5030
+ });
5031
+ res.end(data);
5032
+ }
5033
+ catch {
5034
+ json(res, { ok: false, error: 'Failed to read file' }, 500);
5035
+ }
5036
+ }
5037
+ function handleGetWidgets(res) {
5038
+ try {
5039
+ const store = getDisplayStore();
5040
+ const widgets = store.listEvents();
5041
+ json(res, { ok: true, widgets });
5042
+ }
5043
+ catch (err) {
5044
+ json(res, { ok: false, error: err.message || 'Failed to list widgets' }, 500);
5045
+ }
5046
+ }
5047
+ function handleClearWidgets(res) {
5048
+ try {
5049
+ const store = getDisplayStore();
5050
+ store.clearEvents();
5051
+ json(res, { ok: true });
5052
+ }
5053
+ catch (err) {
5054
+ json(res, { ok: false, error: err.message || 'Failed to clear widgets' }, 500);
5055
+ }
5056
+ }
5057
+ function parseExplorerExperimentId(reqUrl) {
5058
+ return String(reqUrl.searchParams.get('experiment_id') || '').trim();
5059
+ }
5060
+ function parseExplorerRunId(reqUrl) {
5061
+ return String(reqUrl.searchParams.get('run_id') || '').trim();
5062
+ }
5063
+ function parseExplorerListInt(raw, fallback, min, max) {
5064
+ const parsed = Number(raw);
5065
+ if (!Number.isFinite(parsed))
5066
+ return fallback;
5067
+ return Math.max(min, Math.min(max, Math.floor(parsed)));
5068
+ }
5069
+ async function ensureExplorerQuickstartSourceRepo() {
5070
+ if (!(0, fs_1.existsSync)(EXPLORER_TSP_TEMPLATE_DIR)) {
5071
+ throw new Error(`Bundled template not found: ${EXPLORER_TSP_TEMPLATE_DIR}`);
5072
+ }
5073
+ const gitDir = (0, path_1.join)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO, '.git');
5074
+ if ((0, fs_1.existsSync)(gitDir))
5075
+ return EXPLORER_TSP_TEMPLATE_SOURCE_REPO;
5076
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO));
5077
+ (0, fs_1.mkdirSync)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO, { recursive: true, mode: config_js_1.PRIVATE_DIR_MODE });
5078
+ (0, fs_1.cpSync)(EXPLORER_TSP_TEMPLATE_DIR, EXPLORER_TSP_TEMPLATE_SOURCE_REPO, { recursive: true, force: true });
5079
+ await execFileAsync('git', ['init', '.'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
5080
+ await execFileAsync('git', ['add', '.'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
5081
+ try {
5082
+ await execFileAsync('git', ['-c', 'user.name=labgate-ui', '-c', 'user.email=labgate@local', 'commit', '-m', 'template baseline'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
5083
+ }
5084
+ catch (err) {
5085
+ const detail = commandErrorDetail(err);
5086
+ if (!/nothing to commit/i.test(detail)) {
5087
+ throw err;
5088
+ }
5089
+ }
5090
+ return EXPLORER_TSP_TEMPLATE_SOURCE_REPO;
5091
+ }
5092
+ async function parseExplorerQuickstartInput(body) {
5093
+ const nameInput = String(body.name || '').trim();
5094
+ const experimentName = nameInput || `TSP Demo ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`;
5095
+ const modeRaw = String(body.agent_mode || 'stub').trim().toLowerCase();
5096
+ const agentMode = modeRaw === 'claude' ? 'claude_headless' : (modeRaw || 'stub');
5097
+ if (agentMode !== 'stub' && agentMode !== 'claude_headless') {
5098
+ throw new Error('agent_mode must be stub or claude_headless');
5099
+ }
5100
+ const claudeResumeSessionId = String(body.claude_resume_session_id || '').trim();
5101
+ const claudeTimeoutRaw = body.claude_timeout_sec;
5102
+ const claudeTimeout = Number(claudeTimeoutRaw);
5103
+ if (claudeTimeoutRaw !== undefined &&
5104
+ (!Number.isFinite(claudeTimeout) || claudeTimeout < 60 || claudeTimeout > 14_400)) {
5105
+ throw new Error('claude_timeout_sec must be between 60 and 14400');
5106
+ }
5107
+ const sourceRepoInput = String(body.source_repo_path || '').trim();
5108
+ const sourceRepoPath = sourceRepoInput
5109
+ ? (0, path_1.resolve)(sourceRepoInput)
5110
+ : await ensureExplorerQuickstartSourceRepo();
5111
+ if (!(0, fs_1.existsSync)(sourceRepoPath)) {
5112
+ throw new Error(`source_repo_path not found: ${sourceRepoPath}`);
5113
+ }
5114
+ const evalCommand = String(body.eval_command || 'python3 eval.py').trim() || 'python3 eval.py';
5115
+ const timeoutRaw = body.eval_timeout_sec;
5116
+ const evalTimeoutSec = timeoutRaw === undefined ? 30 : Number(timeoutRaw);
5117
+ if (!Number.isFinite(evalTimeoutSec) || evalTimeoutSec < 5 || evalTimeoutSec > 86_400) {
5118
+ throw new Error('eval_timeout_sec must be between 5 and 86400');
5119
+ }
5120
+ const policy = {
5121
+ epsilon: 0.15,
5122
+ top_n: 5,
5123
+ agent_mode: agentMode,
5124
+ };
5125
+ if (agentMode === 'stub') {
5126
+ policy.stub_patch_file = 'stub-patches/enable_two_opt.patch';
5127
+ }
5128
+ else {
5129
+ if (claudeResumeSessionId)
5130
+ policy.claude_resume_session_id = claudeResumeSessionId;
5131
+ if (claudeTimeoutRaw !== undefined)
5132
+ policy.claude_timeout_sec = Math.floor(claudeTimeout);
5133
+ }
5134
+ return {
5135
+ experimentName,
5136
+ sourceRepoPath,
5137
+ evalCommand,
5138
+ evalTimeoutSec: Math.floor(evalTimeoutSec),
5139
+ policy,
5140
+ };
5141
+ }
5142
+ function handleGetExplorerExperiments(reqUrl, res) {
5143
+ const limit = parseExplorerListInt(reqUrl.searchParams.get('limit'), 20, 1, 500);
5144
+ const offset = parseExplorerListInt(reqUrl.searchParams.get('offset'), 0, 0, 100_000);
5145
+ const store = new explorer_store_js_1.ExplorerStore();
5146
+ try {
5147
+ const experiments = store.listExperiments(limit, offset);
5148
+ json(res, {
5149
+ ok: true,
5150
+ experiments,
5151
+ returned: experiments.length,
5152
+ limit,
5153
+ offset,
5154
+ });
5155
+ }
5156
+ catch (err) {
5157
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5158
+ }
5159
+ finally {
5160
+ store.close();
5161
+ }
5162
+ }
5163
+ function handleGetExplorerOverview(reqUrl, res) {
5164
+ const experimentId = parseExplorerExperimentId(reqUrl);
5165
+ if (!experimentId) {
5166
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5167
+ return;
5168
+ }
5169
+ try {
5170
+ const overview = (0, explorer_js_1.getExperimentOverview)(experimentId);
5171
+ if (!overview) {
5172
+ json(res, { ok: false, error: 'Experiment not found' }, 404);
5173
+ return;
5174
+ }
5175
+ json(res, { ok: true, overview });
5176
+ }
5177
+ catch (err) {
5178
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5179
+ }
5180
+ }
5181
+ function handleGetExplorerTree(reqUrl, res) {
5182
+ const experimentId = parseExplorerExperimentId(reqUrl);
5183
+ if (!experimentId) {
5184
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5185
+ return;
5186
+ }
5187
+ const mode = String(reqUrl.searchParams.get('mode') || 'best_path').trim() === 'full' ? 'full' : 'best_path';
5188
+ try {
5189
+ const tree = (0, explorer_js_1.getExperimentTree)(experimentId, mode);
5190
+ json(res, { ok: true, tree });
5191
+ }
5192
+ catch (err) {
5193
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5194
+ }
5195
+ }
5196
+ function handleGetExplorerRuns(reqUrl, res) {
5197
+ const experimentId = parseExplorerExperimentId(reqUrl);
5198
+ if (!experimentId) {
5199
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5200
+ return;
5201
+ }
5202
+ const limit = parseExplorerListInt(reqUrl.searchParams.get('limit'), 20, 1, 500);
5203
+ const offset = parseExplorerListInt(reqUrl.searchParams.get('offset'), 0, 0, 100_000);
5204
+ const store = new explorer_store_js_1.ExplorerStore();
5205
+ try {
5206
+ const runs = store.listRuns(experimentId, { limit, offset });
5207
+ const total = store.getRunCount(experimentId);
5208
+ json(res, { ok: true, runs, total, returned: runs.length, limit, offset });
5209
+ }
5210
+ catch (err) {
5211
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5212
+ }
5213
+ finally {
5214
+ store.close();
5215
+ }
5216
+ }
5217
+ function handleGetExplorerRun(reqUrl, res) {
5218
+ const runId = parseExplorerRunId(reqUrl);
5219
+ if (!runId) {
5220
+ json(res, { ok: false, error: 'Missing run_id' }, 400);
5221
+ return;
5222
+ }
5223
+ try {
5224
+ const details = (0, explorer_js_1.getRunDetails)(runId);
5225
+ if (!details) {
5226
+ json(res, { ok: false, error: 'Run not found' }, 404);
5227
+ return;
5228
+ }
5229
+ json(res, { ok: true, details });
5230
+ }
5231
+ catch (err) {
5232
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5233
+ }
5234
+ }
5235
+ function handleGetExplorerCompare(reqUrl, res) {
5236
+ const experimentId = parseExplorerExperimentId(reqUrl);
5237
+ const runId = parseExplorerRunId(reqUrl);
5238
+ if (!experimentId || !runId) {
5239
+ json(res, { ok: false, error: 'Missing experiment_id or run_id' }, 400);
5240
+ return;
5241
+ }
5242
+ const compareTo = String(reqUrl.searchParams.get('to') || 'best').trim() || 'best';
5243
+ const includePatch = reqUrl.searchParams.get('include_patch') === '1';
5244
+ try {
5245
+ const comparison = (0, explorer_js_1.compareRun)({
5246
+ experiment_id: experimentId,
5247
+ run_id: runId,
5248
+ compare_to: compareTo,
5249
+ include_patch: includePatch,
5250
+ });
5251
+ json(res, { ok: true, comparison });
5252
+ }
5253
+ catch (err) {
5254
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5255
+ }
5256
+ }
5257
+ function handleGetExplorerArtifact(reqUrl, res) {
5258
+ const runId = parseExplorerRunId(reqUrl);
5259
+ if (!runId) {
5260
+ json(res, { ok: false, error: 'Missing run_id' }, 400);
5261
+ return;
5262
+ }
5263
+ const kind = String(reqUrl.searchParams.get('kind') || 'summary').trim().toLowerCase();
5264
+ if (!['summary', 'diff', 'stdout', 'stderr', 'eval', 'agent', 'claude_stdout', 'claude_stderr'].includes(kind)) {
5265
+ json(res, { ok: false, error: 'Invalid kind (expected summary|diff|stdout|stderr|eval|agent|claude_stdout|claude_stderr)' }, 400);
5266
+ return;
5267
+ }
5268
+ const details = (0, explorer_js_1.getRunDetails)(runId);
5269
+ if (!details) {
5270
+ json(res, { ok: false, error: 'Run not found' }, 404);
5271
+ return;
5272
+ }
5273
+ const artifacts = details.artifacts;
5274
+ const pathByKind = {
5275
+ summary: artifacts.summary_path,
5276
+ diff: artifacts.diff_path,
5277
+ stdout: artifacts.stdout_path,
5278
+ stderr: artifacts.stderr_path,
5279
+ eval: artifacts.eval_json_path,
5280
+ agent: artifacts.agent_log_path,
5281
+ claude_stdout: artifacts.claude_stdout_path,
5282
+ claude_stderr: artifacts.claude_stderr_path,
5283
+ };
5284
+ const availableByKind = {
5285
+ summary: artifacts.available.summary,
5286
+ diff: artifacts.available.diff,
5287
+ stdout: artifacts.available.stdout,
5288
+ stderr: artifacts.available.stderr,
5289
+ eval: artifacts.available.eval_json,
5290
+ agent: artifacts.available.agent_log,
5291
+ claude_stdout: artifacts.available.claude_stdout,
5292
+ claude_stderr: artifacts.available.claude_stderr,
5293
+ };
5294
+ const filePath = pathByKind[kind];
5295
+ if (!filePath || !availableByKind[kind] || !(0, fs_1.existsSync)(filePath)) {
5296
+ json(res, {
5297
+ ok: false,
5298
+ error: 'Artifact missing (possibly pruned)',
5299
+ artifacts_pruned: artifacts.artifacts_pruned,
5300
+ worktree_pruned: artifacts.worktree_pruned,
5301
+ }, 404);
5302
+ return;
5303
+ }
5304
+ try {
5305
+ const st = (0, fs_1.statSync)(filePath);
5306
+ if (!st.isFile()) {
5307
+ json(res, { ok: false, error: 'Artifact is not a file' }, 400);
5308
+ return;
5309
+ }
5310
+ const readBytes = Math.min(st.size, EXPLORER_ARTIFACT_READ_MAX_BYTES);
5311
+ const offset = Math.max(0, st.size - readBytes);
5312
+ const fd = (0, fs_1.openSync)(filePath, 'r');
5313
+ const buf = Buffer.alloc(readBytes);
5314
+ try {
5315
+ (0, fs_1.readSync)(fd, buf, 0, readBytes, offset);
5316
+ }
5317
+ finally {
5318
+ (0, fs_1.closeSync)(fd);
5319
+ }
5320
+ let text = buf.toString('utf-8');
5321
+ if (offset > 0) {
5322
+ const firstNewline = text.indexOf('\n');
5323
+ if (firstNewline >= 0)
5324
+ text = text.slice(firstNewline + 1);
5325
+ }
5326
+ if (kind === 'eval') {
5327
+ let parsedEval = null;
5328
+ try {
5329
+ parsedEval = JSON.parse(text);
5330
+ }
5331
+ catch {
5332
+ parsedEval = null;
5333
+ }
5334
+ json(res, {
5335
+ ok: true,
5336
+ kind,
5337
+ path: filePath,
5338
+ size: st.size,
5339
+ truncated: st.size > readBytes,
5340
+ eval: parsedEval,
5341
+ raw: text,
5342
+ });
5343
+ return;
5344
+ }
5345
+ json(res, {
5346
+ ok: true,
5347
+ kind,
5348
+ path: filePath,
5349
+ size: st.size,
5350
+ truncated: st.size > readBytes,
5351
+ text,
5352
+ });
5353
+ }
5354
+ catch (err) {
5355
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5356
+ }
5357
+ }
5358
+ async function handlePostExplorerQuickstart(req, res) {
5359
+ try {
5360
+ let body = {};
5361
+ try {
5362
+ body = JSON.parse(await readBody(req) || '{}');
5363
+ }
5364
+ catch {
5365
+ body = {};
5366
+ }
5367
+ const input = await parseExplorerQuickstartInput(body);
5368
+ const experiment = (0, explorer_js_1.createExplorerExperiment)({
5369
+ name: input.experimentName,
5370
+ source_repo_path: input.sourceRepoPath,
5371
+ eval_command: input.evalCommand,
5372
+ eval_timeout_sec: input.evalTimeoutSec,
5373
+ policy: input.policy,
5374
+ retention: {
5375
+ keep_worktrees: false,
5376
+ artifacts: 'minimal',
5377
+ keep_last_n: 50,
5378
+ keep_best: true,
5379
+ keep_failed_last_n: 20,
5380
+ max_delete_runs: 200,
5381
+ },
5382
+ });
5383
+ const baselineArtifactDir = (0, config_js_1.getExplorerArtifactDir)(experiment.id, 'baseline');
5384
+ const baseline = (0, explorer_eval_js_1.runEvaluation)({
5385
+ worktree_path: experiment.repo_path,
5386
+ eval_command: experiment.eval_command,
5387
+ timeout_sec: experiment.eval_timeout_sec,
5388
+ artifact_dir: baselineArtifactDir,
5389
+ });
5390
+ const store = new explorer_store_js_1.ExplorerStore();
5391
+ try {
5392
+ store.createEvent(experiment.id, 'note', {
5393
+ message: 'baseline evaluation',
5394
+ status: baseline.status,
5395
+ score: baseline.score ?? null,
5396
+ artifact_dir: baselineArtifactDir,
5397
+ error: baseline.error || null,
5398
+ });
5399
+ }
5400
+ finally {
5401
+ store.close();
5402
+ }
5403
+ const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id);
5404
+ json(res, {
5405
+ ok: true,
5406
+ experiment,
5407
+ baseline: {
5408
+ status: baseline.status,
5409
+ score: baseline.score ?? null,
5410
+ error: baseline.error || null,
5411
+ artifact_dir: baselineArtifactDir,
5412
+ },
5413
+ overview,
5414
+ });
5415
+ }
5416
+ catch (err) {
5417
+ const message = err?.message || String(err);
5418
+ const status = /must be|not found/i.test(message) ? 400 : 500;
5419
+ json(res, { ok: false, error: message }, status);
5420
+ }
5421
+ }
5422
+ async function handlePostExplorerRegister(req, res) {
5423
+ try {
5424
+ let body = {};
5425
+ try {
5426
+ body = JSON.parse(await readBody(req) || '{}');
5427
+ }
5428
+ catch {
5429
+ body = {};
5430
+ }
5431
+ const input = await parseExplorerQuickstartInput(body);
5432
+ const experiment = (0, explorer_js_1.createExplorerExperiment)({
5433
+ name: input.experimentName,
5434
+ source_repo_path: input.sourceRepoPath,
5435
+ eval_command: input.evalCommand,
5436
+ eval_timeout_sec: input.evalTimeoutSec,
5437
+ policy: input.policy,
5438
+ retention: {
5439
+ keep_worktrees: false,
5440
+ artifacts: 'minimal',
5441
+ keep_last_n: 50,
5442
+ keep_best: true,
5443
+ keep_failed_last_n: 20,
5444
+ max_delete_runs: 200,
5445
+ },
5446
+ });
5447
+ const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id);
5448
+ json(res, {
5449
+ ok: true,
5450
+ experiment,
5451
+ overview,
5452
+ flow: {
5453
+ tool: 'experiment_register',
5454
+ initialized: false,
5455
+ },
5456
+ });
5457
+ }
5458
+ catch (err) {
5459
+ const message = err?.message || String(err);
5460
+ const status = /must be|not found/i.test(message) ? 400 : 500;
5461
+ json(res, { ok: false, error: message }, status);
5462
+ }
5463
+ }
5464
+ async function handlePostExplorerInit(req, res) {
5465
+ try {
5466
+ let body = {};
5467
+ try {
5468
+ body = JSON.parse(await readBody(req) || '{}');
5469
+ }
5470
+ catch {
5471
+ body = {};
5472
+ }
5473
+ const experimentId = String(body.experiment_id || '').trim();
5474
+ if (!experimentId) {
5475
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5476
+ return;
5477
+ }
5478
+ const store = new explorer_store_js_1.ExplorerStore();
5479
+ try {
5480
+ const experiment = store.getExperiment(experimentId);
5481
+ if (!experiment) {
5482
+ json(res, { ok: false, error: 'Experiment not found' }, 404);
5483
+ return;
5484
+ }
5485
+ const baselineArtifactDir = (0, config_js_1.getExplorerArtifactDir)(experiment.id, 'baseline');
5486
+ const baseline = (0, explorer_eval_js_1.runEvaluation)({
5487
+ worktree_path: experiment.repo_path,
5488
+ eval_command: experiment.eval_command,
5489
+ timeout_sec: experiment.eval_timeout_sec,
5490
+ artifact_dir: baselineArtifactDir,
5491
+ });
5492
+ store.createEvent(experiment.id, 'note', {
5493
+ message: 'experiment initialized with baseline evaluation',
5494
+ status: baseline.status,
5495
+ score: baseline.score ?? null,
5496
+ artifact_dir: baselineArtifactDir,
5497
+ error: baseline.error || null,
5498
+ });
5499
+ const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id, store);
5500
+ json(res, {
5501
+ ok: true,
5502
+ experiment,
5503
+ baseline: {
5504
+ status: baseline.status,
5505
+ score: baseline.score ?? null,
5506
+ error: baseline.error || null,
5507
+ artifact_dir: baselineArtifactDir,
5508
+ },
5509
+ overview,
5510
+ flow: {
5511
+ tool: 'experiment_init',
5512
+ initialized: true,
5513
+ },
5514
+ });
5515
+ }
5516
+ finally {
5517
+ store.close();
5518
+ }
5519
+ }
5520
+ catch (err) {
5521
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5522
+ }
5523
+ }
5524
+ async function handlePostExplorerTick(req, res) {
5525
+ try {
5526
+ let body = {};
5527
+ try {
5528
+ body = JSON.parse(await readBody(req) || '{}');
5529
+ }
5530
+ catch {
5531
+ body = {};
5532
+ }
5533
+ const experimentId = String(body.experiment_id || '').trim();
5534
+ if (!experimentId) {
5535
+ json(res, { ok: false, error: 'Missing experiment_id' }, 400);
5536
+ return;
5537
+ }
5538
+ const result = (0, explorer_js_1.runAutopilotTick)(experimentId);
5539
+ const runDetails = result.run_id ? (0, explorer_js_1.getRunDetails)(result.run_id) : null;
5540
+ json(res, { ok: true, result, run_details: runDetails });
5541
+ }
5542
+ catch (err) {
5543
+ json(res, { ok: false, error: err?.message || String(err) }, 500);
5544
+ }
5545
+ }
4730
5546
  function upgradeUnauthorized(socket) {
4731
5547
  try {
4732
5548
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
@@ -5123,6 +5939,54 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
5123
5939
  else if (pathname === '/api/admin/license' && method === 'GET') {
5124
5940
  handleGetAdminLicense(req, res);
5125
5941
  }
5942
+ else if (pathname === '/api/explorer/experiments' && method === 'GET') {
5943
+ handleGetExplorerExperiments(reqUrl, res);
5944
+ }
5945
+ else if (pathname === '/api/explorer/overview' && method === 'GET') {
5946
+ handleGetExplorerOverview(reqUrl, res);
5947
+ }
5948
+ else if (pathname === '/api/explorer/tree' && method === 'GET') {
5949
+ handleGetExplorerTree(reqUrl, res);
5950
+ }
5951
+ else if (pathname === '/api/explorer/runs' && method === 'GET') {
5952
+ handleGetExplorerRuns(reqUrl, res);
5953
+ }
5954
+ else if (pathname === '/api/explorer/run' && method === 'GET') {
5955
+ handleGetExplorerRun(reqUrl, res);
5956
+ }
5957
+ else if (pathname === '/api/explorer/compare' && method === 'GET') {
5958
+ handleGetExplorerCompare(reqUrl, res);
5959
+ }
5960
+ else if (pathname === '/api/explorer/artifact' && method === 'GET') {
5961
+ handleGetExplorerArtifact(reqUrl, res);
5962
+ }
5963
+ else if (pathname === '/api/explorer/register' && method === 'POST') {
5964
+ await handlePostExplorerRegister(req, res);
5965
+ }
5966
+ else if (pathname === '/api/explorer/init' && method === 'POST') {
5967
+ await handlePostExplorerInit(req, res);
5968
+ }
5969
+ else if (pathname === '/api/explorer/quickstart' && method === 'POST') {
5970
+ await handlePostExplorerQuickstart(req, res);
5971
+ }
5972
+ else if (pathname === '/api/explorer/tick' && method === 'POST') {
5973
+ await handlePostExplorerTick(req, res);
5974
+ }
5975
+ else if (pathname === '/api/explorer/step' && method === 'POST') {
5976
+ await handlePostExplorerTick(req, res);
5977
+ }
5978
+ else if (pathname === '/api/explorer/go' && method === 'POST') {
5979
+ await handlePostExplorerTick(req, res);
5980
+ }
5981
+ else if (pathname === '/api/display/file' && method === 'GET') {
5982
+ handleDisplayFile(reqUrl, res);
5983
+ }
5984
+ else if (pathname === '/api/widgets' && method === 'GET') {
5985
+ handleGetWidgets(res);
5986
+ }
5987
+ else if (pathname === '/api/widgets/clear' && method === 'POST') {
5988
+ handleClearWidgets(res);
5989
+ }
5126
5990
  else if (pathname.startsWith('/fonts/') && method === 'GET') {
5127
5991
  serveFontFile(pathname, res);
5128
5992
  }
@@ -5278,7 +6142,8 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
5278
6142
  }
5279
6143
  if (useTcp) {
5280
6144
  const actualPort = server.address()?.port ?? listenPort;
5281
- dashboardQuickLink = `http://localhost:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
6145
+ // Use an explicit IPv4 loopback host to avoid `localhost` IPv6 collisions on macOS.
6146
+ dashboardQuickLink = `http://127.0.0.1:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
5282
6147
  log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
5283
6148
  try {
5284
6149
  writeDashboardLink(dashboardQuickLink);
@@ -5387,6 +6252,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
5387
6252
  }
5388
6253
  sseClients.clear();
5389
6254
  stopResultsWatcher();
6255
+ stopDisplayWatcher();
5390
6256
  // Cleanup SLURM resources
5391
6257
  if (slurmPoller) {
5392
6258
  slurmPoller.stop();
@@ -5400,6 +6266,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
5400
6266
  slurmDB = null;
5401
6267
  }
5402
6268
  resultsStore = null;
6269
+ displayStore = null;
5403
6270
  });
5404
6271
  return server;
5405
6272
  }