labgate 0.5.36 → 0.5.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/ui.js CHANGED
@@ -57,6 +57,8 @@ const web_terminal_js_1 = require("./web-terminal.js");
57
57
  const explorer_js_1 = require("./explorer.js");
58
58
  const explorer_eval_js_1 = require("./explorer-eval.js");
59
59
  const explorer_store_js_1 = require("./explorer-store.js");
60
+ const feedback_js_1 = require("./feedback.js");
61
+ const automation_engine_js_1 = require("./automation-engine.js");
60
62
  const log = __importStar(require("./log.js"));
61
63
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
62
64
  const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
@@ -86,6 +88,15 @@ const UI_VERSION_CHECK_MAX_BUFFER = 256 * 1024;
86
88
  const UI_VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
87
89
  const UI_SELF_UPDATE_TIMEOUT_MS = 20 * 60 * 1000;
88
90
  const UI_SELF_UPDATE_MAX_BUFFER = 16 * 1024 * 1024;
91
+ const OPEN_ONDEMAND_ENV_PREFIXES = ['OOD_', 'ONDEMAND_'];
92
+ const SESSION_GIT_CACHE_TTL_MS = 4_000;
93
+ const SESSION_GIT_COMMAND_TIMEOUT_MS = 1_500;
94
+ const SESSION_GIT_COMMAND_MAX_BUFFER = 512 * 1024;
95
+ const SESSION_GIT_MUTATION_TIMEOUT_MS = 5_000;
96
+ const BROWSE_DIR_GIT_STATUS_TIMEOUT_MS = 2_500;
97
+ const FILE_PREVIEW_DEFAULT_MAX_BYTES = 256 * 1024;
98
+ const FILE_PREVIEW_MAX_BYTES_LIMIT = 1 * 1024 * 1024;
99
+ const FILE_PREVIEW_BINARY_SCAN_BYTES = 4096;
89
100
  const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
90
101
  '\n' +
91
102
  'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
@@ -105,6 +116,7 @@ let resultsStore = null;
105
116
  let displayStore = null;
106
117
  const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
107
118
  const webTerminalBridges = new Map();
119
+ const automationEngines = new Map();
108
120
  const WEB_TERMINAL_INIT_RETENTION_MS = 60 * 60 * 1000;
109
121
  const WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS = 60 * 60 * 1000;
110
122
  const WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER = 64 * 1024 * 1024;
@@ -125,6 +137,7 @@ const CLAUDE_HEADLESS_STDERR_LIMIT = 12_000;
125
137
  const webTerminalInitJobs = new Map();
126
138
  const webTerminalImagePullLocks = new Map();
127
139
  const webTerminalAgentPrepLocks = new Map();
140
+ const sessionGitStateCache = new Map();
128
141
  const LABGATE_UI_VERSION = readPackageVersion();
129
142
  let uiPublishedVersionCache = null;
130
143
  let uiPublishedVersionInFlight = null;
@@ -522,11 +535,12 @@ async function prepareRuntimeForWebTerminal(preferred) {
522
535
  function createWebTerminalInitId() {
523
536
  return `wti-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
524
537
  }
525
- function createWebTerminalInitJob(agent, workdir) {
538
+ function createWebTerminalInitJob(agent, workdir, permissionMode) {
526
539
  const now = new Date().toISOString();
527
540
  return {
528
541
  id: createWebTerminalInitId(),
529
542
  agent,
543
+ permissionMode,
530
544
  workdir,
531
545
  status: 'running',
532
546
  stage: 'queued',
@@ -567,6 +581,7 @@ function serializeWebTerminalInitJob(job) {
567
581
  return {
568
582
  id: job.id,
569
583
  agent: job.agent,
584
+ permissionMode: job.permissionMode,
570
585
  workdir: job.workdir,
571
586
  status: job.status,
572
587
  stage: job.stage,
@@ -873,7 +888,9 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
873
888
  (0, web_terminal_js_1.writeWebTerminalRecord)(record);
874
889
  onProgress?.('session_start', `Starting ${agent} terminal session...`);
875
890
  try {
876
- await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint);
891
+ await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint, {
892
+ permissionMode: opts.permissionMode || 'default',
893
+ });
877
894
  (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
878
895
  }
879
896
  catch (err) {
@@ -1192,6 +1209,16 @@ function normalizeWebTerminalAgent(raw) {
1192
1209
  return agent;
1193
1210
  return null;
1194
1211
  }
1212
+ function normalizeWebTerminalPermissionMode(raw) {
1213
+ if (raw === undefined || raw === null)
1214
+ return 'default';
1215
+ const mode = String(raw || '').trim().toLowerCase();
1216
+ if (!mode || mode === 'default')
1217
+ return 'default';
1218
+ if (mode === 'dangerous')
1219
+ return 'dangerous';
1220
+ return null;
1221
+ }
1195
1222
  function serializeWebTerminalSession(record) {
1196
1223
  return {
1197
1224
  id: record.id,
@@ -1477,13 +1504,32 @@ async function ensureWebTerminalBridge(record) {
1477
1504
  log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
1478
1505
  return null;
1479
1506
  }
1480
- try {
1507
+ const sessionOptions = [
1481
1508
  // Keep web terminal copy/selection behavior reliable by letting xterm own
1482
1509
  // mouse selection instead of tmux copy-mode selection.
1483
- await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, 'mouse', 'off'], { timeout: 10_000 });
1510
+ ['mouse', 'off'],
1511
+ // Hide tmux status/footer rows in web terminal sessions.
1512
+ ['status', 'off'],
1513
+ ];
1514
+ for (const [key, value] of sessionOptions) {
1515
+ try {
1516
+ await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, key, value], { timeout: 10_000 });
1517
+ }
1518
+ catch {
1519
+ // Best effort only; attach should still proceed.
1520
+ }
1484
1521
  }
1485
- catch {
1486
- // Best effort only; attach should still proceed.
1522
+ const windowOptions = [
1523
+ // Avoid tmux alternate-screen mode so TUI output remains scrollable in xterm/web UI.
1524
+ ['alternate-screen', 'off'],
1525
+ ];
1526
+ for (const [key, value] of windowOptions) {
1527
+ try {
1528
+ await execFileAsync(tmuxBin, ['set-window-option', '-t', record.tmuxSession, key, value], { timeout: 10_000 });
1529
+ }
1530
+ catch {
1531
+ // Best effort only; attach should still proceed.
1532
+ }
1487
1533
  }
1488
1534
  if (existing && existing.pty)
1489
1535
  return existing;
@@ -1544,6 +1590,10 @@ async function ensureWebTerminalBridge(record) {
1544
1590
  seqEnd: chunk.seq,
1545
1591
  });
1546
1592
  }
1593
+ // Automation hook: feed output to engine if one exists for this terminal
1594
+ const engine = automationEngines.get(record.id);
1595
+ if (engine)
1596
+ engine.onTerminalOutput(data);
1547
1597
  });
1548
1598
  ptyProcess.onExit(async () => {
1549
1599
  const stopRequested = bridge.stopRequested;
@@ -1551,14 +1601,20 @@ async function ensureWebTerminalBridge(record) {
1551
1601
  bridge.pty = null;
1552
1602
  const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
1553
1603
  if (alive) {
1554
- if (stopRequested)
1555
- return;
1556
- // Another tmux client (for example `labgate continue`) may have force-detached this bridge.
1557
- // Clear stale alternate-screen data and require clients to reconnect for a clean reattach.
1604
+ // The attach bridge process exited while tmux is still alive (for example
1605
+ // after a deliberate detach during session switch). Drop transient bridge
1606
+ // output to avoid replaying stale lifecycle lines like "[terminated]" on
1607
+ // the next attach.
1558
1608
  bridge.buffer = '';
1559
1609
  bridge.history = [];
1560
1610
  bridge.historyBytes = 0;
1561
- closeWebTerminalBridgeClients(bridge);
1611
+ bridge.nextSeq = 1;
1612
+ if (!stopRequested) {
1613
+ // Another tmux client (for example `labgate continue`) may have
1614
+ // force-detached this bridge. Require clients to reconnect for a clean
1615
+ // reattach.
1616
+ closeWebTerminalBridgeClients(bridge);
1617
+ }
1562
1618
  return;
1563
1619
  }
1564
1620
  const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
@@ -1582,6 +1638,12 @@ function stopWebTerminalBridge(bridge) {
1582
1638
  if (!bridge.pty)
1583
1639
  return;
1584
1640
  bridge.stopRequested = true;
1641
+ // Clean up automation engine for this bridge
1642
+ const engine = automationEngines.get(bridge.id);
1643
+ if (engine) {
1644
+ engine.stop();
1645
+ automationEngines.delete(bridge.id);
1646
+ }
1585
1647
  try {
1586
1648
  bridge.pty.kill('SIGTERM');
1587
1649
  }
@@ -1824,7 +1886,14 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1824
1886
  }
1825
1887
  function serveHTML(res) {
1826
1888
  try {
1827
- const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8').replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN);
1889
+ let uname = '';
1890
+ try {
1891
+ uname = (0, os_1.userInfo)().username;
1892
+ }
1893
+ catch { }
1894
+ const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8')
1895
+ .replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN)
1896
+ .replaceAll('__LABGATE_USER__', uname);
1828
1897
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1829
1898
  res.end(html);
1830
1899
  }
@@ -1837,6 +1906,10 @@ function handleGetConfig(_req, res) {
1837
1906
  const effective = (0, config_js_1.loadEffectiveConfig)();
1838
1907
  const response = { ...effective.config };
1839
1908
  const slurmRuntime = getSlurmRuntimeStatus();
1909
+ response._host = {
1910
+ hostname: (0, os_1.hostname)(),
1911
+ openondemand: isOpenOnDemandHostRuntime(),
1912
+ };
1840
1913
  response._slurm = {
1841
1914
  available: slurmRuntime.available,
1842
1915
  missing_commands: slurmRuntime.missingCommands,
@@ -1855,6 +1928,16 @@ function handleGetConfig(_req, res) {
1855
1928
  }
1856
1929
  json(res, response);
1857
1930
  }
1931
+ function isOpenOnDemandHostRuntime(env = process.env) {
1932
+ const keys = Object.keys(env);
1933
+ for (const key of keys) {
1934
+ for (const prefix of OPEN_ONDEMAND_ENV_PREFIXES) {
1935
+ if (key.startsWith(prefix))
1936
+ return true;
1937
+ }
1938
+ }
1939
+ return false;
1940
+ }
1858
1941
  async function handlePostConfig(req, res) {
1859
1942
  try {
1860
1943
  const body = await readBody(req);
@@ -1925,6 +2008,8 @@ async function handlePostConfig(req, res) {
1925
2008
  obj.slurm = incoming.slurm;
1926
2009
  if (incoming.headless)
1927
2010
  obj.headless = incoming.headless;
2011
+ if (incoming.plugins)
2012
+ obj.plugins = incoming.plugins;
1928
2013
  const { writeFileSync } = await import('fs');
1929
2014
  writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
1930
2015
  (0, config_js_1.ensurePrivateFile)(configPath);
@@ -1937,6 +2022,266 @@ async function handlePostConfig(req, res) {
1937
2022
  function handleGetConfigPath(_req, res) {
1938
2023
  json(res, { path: (0, config_js_1.getConfigPath)() });
1939
2024
  }
2025
+ // ── Plugin API ──────────────────────────────────────────────
2026
+ function isPluginEnabledInConfig(config, pluginId) {
2027
+ return config.plugins?.[pluginId] !== false;
2028
+ }
2029
+ function handleGetPlugins(_req, res) {
2030
+ const effective = (0, config_js_1.loadEffectiveConfig)();
2031
+ const plugins = effective.config.plugins ?? {};
2032
+ json(res, { ok: true, plugins });
2033
+ }
2034
+ async function handlePostPlugins(req, res) {
2035
+ try {
2036
+ const body = await readBody(req);
2037
+ const parsed = JSON.parse(body);
2038
+ const pluginId = typeof parsed.pluginId === 'string' ? parsed.pluginId.trim() : '';
2039
+ const enabled = parsed.enabled;
2040
+ if (!pluginId || typeof enabled !== 'boolean') {
2041
+ json(res, { ok: false, errors: ['pluginId (string) and enabled (boolean) are required'] }, 400);
2042
+ return;
2043
+ }
2044
+ if (!(0, config_js_1.isKnownPluginId)(pluginId)) {
2045
+ json(res, { ok: false, errors: [`Unknown plugin id: ${pluginId}`] }, 400);
2046
+ return;
2047
+ }
2048
+ // Read existing config file
2049
+ const configPath = (0, config_js_1.getConfigPath)();
2050
+ let obj = {};
2051
+ if ((0, fs_1.existsSync)(configPath)) {
2052
+ const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
2053
+ const stripped = rawText
2054
+ .split('\n')
2055
+ .filter(line => !line.trimStart().startsWith('//'))
2056
+ .join('\n');
2057
+ try {
2058
+ obj = JSON.parse(stripped);
2059
+ }
2060
+ catch (err) {
2061
+ json(res, { ok: false, errors: [`Could not parse existing config file: ${err.message ?? String(err)}`] }, 400);
2062
+ return;
2063
+ }
2064
+ }
2065
+ // Merge plugin state
2066
+ const rawPlugins = (obj.plugins ?? {});
2067
+ const plugins = {};
2068
+ if (rawPlugins && typeof rawPlugins === 'object' && !Array.isArray(rawPlugins)) {
2069
+ for (const [id, value] of Object.entries(rawPlugins)) {
2070
+ if (!(0, config_js_1.isKnownPluginId)(id))
2071
+ continue;
2072
+ if (typeof value !== 'boolean')
2073
+ continue;
2074
+ plugins[id] = value;
2075
+ }
2076
+ }
2077
+ plugins[pluginId] = enabled;
2078
+ obj.plugins = plugins;
2079
+ const { writeFileSync } = await import('fs');
2080
+ writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
2081
+ (0, config_js_1.ensurePrivateFile)(configPath);
2082
+ json(res, { ok: true, plugins });
2083
+ }
2084
+ catch (err) {
2085
+ json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
2086
+ }
2087
+ }
2088
+ // ── Automation API ──────────────────────────────────────────
2089
+ function handleGetAutomationStatus(terminalId, res) {
2090
+ const engine = automationEngines.get(terminalId);
2091
+ if (!engine) {
2092
+ json(res, { ok: true, active: false });
2093
+ return;
2094
+ }
2095
+ const status = engine.getStatus();
2096
+ const config = engine.getConfig();
2097
+ // Strip api_key from response
2098
+ const { api_key: _key, ...safeConfig } = config;
2099
+ json(res, { ok: true, active: true, status, config: safeConfig });
2100
+ }
2101
+ async function handlePostAutomationEnable(req, res) {
2102
+ try {
2103
+ const body = await readBody(req);
2104
+ const parsed = JSON.parse(body);
2105
+ const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
2106
+ if (!terminalId) {
2107
+ json(res, { ok: false, errors: ['terminalId is required'] }, 400);
2108
+ return;
2109
+ }
2110
+ if (automationEngines.has(terminalId)) {
2111
+ json(res, { ok: true, message: 'already enabled' });
2112
+ return;
2113
+ }
2114
+ const bridge = webTerminalBridges.get(terminalId);
2115
+ if (!bridge || !bridge.pty) {
2116
+ json(res, { ok: false, errors: ['No active terminal bridge for this id'] }, 400);
2117
+ return;
2118
+ }
2119
+ const effective = (0, config_js_1.loadEffectiveConfig)();
2120
+ const autoConfig = effective.config.automation;
2121
+ if (!autoConfig) {
2122
+ json(res, { ok: false, errors: ['Automation config not found'] }, 400);
2123
+ return;
2124
+ }
2125
+ const broadcastFn = (entry) => {
2126
+ broadcastWebTerminalMessage(bridge, {
2127
+ type: 'automation',
2128
+ subtype: entry.type,
2129
+ data: entry,
2130
+ });
2131
+ };
2132
+ const broadcastTurnFn = (action, turn) => {
2133
+ broadcastWebTerminalMessage(bridge, {
2134
+ type: 'automation_turn',
2135
+ action,
2136
+ turn,
2137
+ });
2138
+ };
2139
+ const writeFn = (data) => {
2140
+ try {
2141
+ bridge.pty?.write(data);
2142
+ }
2143
+ catch { /* best effort */ }
2144
+ };
2145
+ const engine = new automation_engine_js_1.AutomationEngine(terminalId, autoConfig, writeFn, broadcastFn, broadcastTurnFn);
2146
+ automationEngines.set(terminalId, engine);
2147
+ json(res, { ok: true });
2148
+ }
2149
+ catch (err) {
2150
+ json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
2151
+ }
2152
+ }
2153
+ async function handlePostAutomationDisable(req, res) {
2154
+ try {
2155
+ const body = await readBody(req);
2156
+ const parsed = JSON.parse(body);
2157
+ const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
2158
+ if (!terminalId) {
2159
+ json(res, { ok: false, errors: ['terminalId is required'] }, 400);
2160
+ return;
2161
+ }
2162
+ const engine = automationEngines.get(terminalId);
2163
+ if (engine) {
2164
+ engine.stop();
2165
+ automationEngines.delete(terminalId);
2166
+ }
2167
+ json(res, { ok: true });
2168
+ }
2169
+ catch (err) {
2170
+ json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
2171
+ }
2172
+ }
2173
+ async function handlePostAutomationPause(req, res) {
2174
+ try {
2175
+ const body = await readBody(req);
2176
+ const parsed = JSON.parse(body);
2177
+ const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
2178
+ const engine = automationEngines.get(terminalId);
2179
+ if (!engine) {
2180
+ json(res, { ok: false, errors: ['No automation engine for this terminal'] }, 404);
2181
+ return;
2182
+ }
2183
+ engine.pause();
2184
+ json(res, { ok: true, status: engine.getStatus() });
2185
+ }
2186
+ catch (err) {
2187
+ json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
2188
+ }
2189
+ }
2190
+ async function handlePostAutomationResume(req, res) {
2191
+ try {
2192
+ const body = await readBody(req);
2193
+ const parsed = JSON.parse(body);
2194
+ const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
2195
+ const engine = automationEngines.get(terminalId);
2196
+ if (!engine) {
2197
+ json(res, { ok: false, errors: ['No automation engine for this terminal'] }, 404);
2198
+ return;
2199
+ }
2200
+ engine.resume();
2201
+ json(res, { ok: true, status: engine.getStatus() });
2202
+ }
2203
+ catch (err) {
2204
+ json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
2205
+ }
2206
+ }
2207
+ async function handlePostAutomationConfig(req, res) {
2208
+ try {
2209
+ const body = await readBody(req);
2210
+ const parsed = JSON.parse(body);
2211
+ const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
2212
+ const engine = automationEngines.get(terminalId);
2213
+ if (!engine) {
2214
+ json(res, { ok: false, errors: ['No automation engine for this terminal'] }, 404);
2215
+ return;
2216
+ }
2217
+ const update = {};
2218
+ if (typeof parsed.system_prompt === 'string')
2219
+ update.system_prompt = parsed.system_prompt;
2220
+ if (Array.isArray(parsed.trigger_patterns))
2221
+ update.trigger_patterns = parsed.trigger_patterns;
2222
+ if (typeof parsed.model === 'string')
2223
+ update.model = parsed.model;
2224
+ if (typeof parsed.delay_ms === 'number')
2225
+ update.delay_ms = parsed.delay_ms;
2226
+ if (typeof parsed.max_turns === 'number')
2227
+ update.max_turns = parsed.max_turns;
2228
+ if (typeof parsed.context_lines === 'number')
2229
+ update.context_lines = parsed.context_lines;
2230
+ if (typeof parsed.max_tokens === 'number')
2231
+ update.max_tokens = parsed.max_tokens;
2232
+ engine.updateConfig(update);
2233
+ const config = engine.getConfig();
2234
+ const { api_key: _key, ...safeConfig } = config;
2235
+ json(res, { ok: true, config: safeConfig });
2236
+ }
2237
+ catch (err) {
2238
+ json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
2239
+ }
2240
+ }
2241
+ function handleGetAutomationTerminalStatus(terminalId, res) {
2242
+ const engine = automationEngines.get(terminalId);
2243
+ if (engine) {
2244
+ json(res, { ok: true, status: engine.getTerminalStatus() });
2245
+ return;
2246
+ }
2247
+ // Fallback: check bridge buffer directly
2248
+ const bridge = webTerminalBridges.get(terminalId);
2249
+ if (!bridge || !bridge.buffer) {
2250
+ json(res, { ok: true, status: 'idle' });
2251
+ return;
2252
+ }
2253
+ // Basic heuristic: check if last line looks like a prompt awaiting input
2254
+ const stripped = bridge.buffer.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()][AB012]|[78DEHM=>])/g, '')
2255
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
2256
+ const lines = stripped.split('\n');
2257
+ const lastLines = lines.slice(-10).join('\n');
2258
+ // Common patterns indicating waiting for input
2259
+ const awaitingPatterns = [/\?\s*$/, /Y\/n\]?\s*$/, /\[yes\/no\]\s*$/i, />\s*$/, /\$\s*$/, /:\s*$/];
2260
+ let isAwaiting = false;
2261
+ for (const pat of awaitingPatterns) {
2262
+ if (pat.test(lastLines)) {
2263
+ isAwaiting = true;
2264
+ break;
2265
+ }
2266
+ }
2267
+ json(res, { ok: true, status: isAwaiting ? 'awaiting_input' : 'working' });
2268
+ }
2269
+ function handleGetAutomationLog(terminalId, res) {
2270
+ const engine = automationEngines.get(terminalId);
2271
+ if (!engine) {
2272
+ json(res, { ok: true, log: [] });
2273
+ return;
2274
+ }
2275
+ json(res, { ok: true, log: engine.getLog() });
2276
+ }
2277
+ function handleGetAutomationTurns(terminalId, res) {
2278
+ const engine = automationEngines.get(terminalId);
2279
+ if (!engine) {
2280
+ json(res, { ok: true, turns: [] });
2281
+ return;
2282
+ }
2283
+ json(res, { ok: true, turns: engine.getTurns() });
2284
+ }
1940
2285
  async function handleGetUiVersion(reqUrl, res) {
1941
2286
  const forceRefresh = reqUrl.searchParams.get('refresh') === '1';
1942
2287
  const published = await getPublishedUiVersionCached(forceRefresh);
@@ -2418,6 +2763,21 @@ function rememberTimestampedEntry(map, value, ts) {
2418
2763
  if (ts >= existing)
2419
2764
  map.set(value, ts);
2420
2765
  }
2766
+ /** Track a file access with action type. Write/edit actions upgrade read; newer timestamps win. */
2767
+ function rememberFileEntry(map, path, ts, action) {
2768
+ if (!path)
2769
+ return;
2770
+ const existing = map.get(path);
2771
+ if (!existing) {
2772
+ map.set(path, { ts, action });
2773
+ return;
2774
+ }
2775
+ // Keep the most significant action (edit > write > unknown > read)
2776
+ const rank = { read: 0, unknown: 1, write: 2, edit: 3 };
2777
+ const newAction = (rank[action] ?? 0) > (rank[existing.action] ?? 0) ? action : existing.action;
2778
+ const newTs = ts >= existing.ts ? ts : existing.ts;
2779
+ map.set(path, { ts: newTs, action: newAction });
2780
+ }
2421
2781
  function collectWebsiteUrls(value, accessedUrls, ts, keyHint = '') {
2422
2782
  if (typeof value === 'string') {
2423
2783
  for (const url of extractWebsiteUrlsFromText(value)) {
@@ -2494,14 +2854,20 @@ function tailLastJsonlEntry(filePath) {
2494
2854
  continue;
2495
2855
  const name = block.name || '';
2496
2856
  collectWebsiteUrls(block.input, accessedUrls, ts);
2857
+ // Classify tool action
2858
+ const toolAction = (name === 'Edit' || name === 'edit') ? 'edit' :
2859
+ (name === 'Write' || name === 'write' || name === 'NotebookEdit') ? 'write' :
2860
+ (name === 'Read' || name === 'read' || name === 'Glob' || name === 'glob') ? 'read' :
2861
+ (name === 'Grep' || name === 'grep') ? 'read' :
2862
+ 'unknown';
2497
2863
  // Direct file_path from Read/Edit/Write/Glob tools
2498
2864
  const fp = block.input.file_path || block.input.path || '';
2499
- if (fp && !accessedFiles.has(fp)) {
2500
- rememberTimestampedEntry(accessedFiles, fp, ts);
2865
+ if (fp) {
2866
+ rememberFileEntry(accessedFiles, fp, ts, toolAction);
2501
2867
  }
2502
2868
  // Grep pattern → search path
2503
2869
  if ((name === 'Grep' || name === 'grep') && block.input.path) {
2504
- rememberTimestampedEntry(accessedFiles, block.input.path, ts);
2870
+ rememberFileEntry(accessedFiles, block.input.path, ts, 'read');
2505
2871
  }
2506
2872
  // Extract file paths from Bash commands
2507
2873
  if (name === 'Bash' || name === 'bash') {
@@ -2511,7 +2877,7 @@ function tailLastJsonlEntry(filePath) {
2511
2877
  if (absMatches) {
2512
2878
  for (const p of absMatches) {
2513
2879
  const clean = p.replace(/['"`;,)}\]]+$/, '');
2514
- rememberTimestampedEntry(accessedFiles, clean, ts);
2880
+ rememberFileEntry(accessedFiles, clean, ts, 'unknown');
2515
2881
  }
2516
2882
  }
2517
2883
  for (const url of extractWebsiteUrlsFromText(cmd)) {
@@ -2527,7 +2893,7 @@ function tailLastJsonlEntry(filePath) {
2527
2893
  const fileArg = tokens[tokens.length - 1] || '';
2528
2894
  if (fileArg && !fileArg.startsWith('/') && !fileArg.startsWith('-') && (fileArg.includes('/') || fileArg.includes('.'))) {
2529
2895
  const full = '/work/' + fileArg;
2530
- rememberTimestampedEntry(accessedFiles, full, ts);
2896
+ rememberFileEntry(accessedFiles, full, ts, 'unknown');
2531
2897
  }
2532
2898
  }
2533
2899
  }
@@ -2545,7 +2911,7 @@ function tailLastJsonlEntry(filePath) {
2545
2911
  if (pathMatches) {
2546
2912
  for (const p of pathMatches.slice(0, 20)) { // cap to avoid perf issues
2547
2913
  const clean = p.replace(/['"`;,)}\]]+$/, '');
2548
- rememberTimestampedEntry(accessedFiles, clean, ts);
2914
+ rememberFileEntry(accessedFiles, clean, ts, 'unknown');
2549
2915
  }
2550
2916
  }
2551
2917
  for (const url of extractWebsiteUrlsFromText(text).slice(0, 30)) {
@@ -2581,13 +2947,25 @@ function tailLastJsonlEntry(filePath) {
2581
2947
  return null;
2582
2948
  }
2583
2949
  }
2584
- /** Cache for JSONL file scanning (refreshed every 3s) */
2950
+ /** Cache for JSONL file scanning and short-lived session activity snapshots. */
2585
2951
  let jsonlCache = null;
2952
+ const JSONL_CACHE_TTL_MS = 10_000;
2953
+ const JSONL_SCAN_MAX_ENTRIES = 6_000;
2954
+ const SESSION_ACTIVITY_CACHE_TTL_MS = 8_000;
2955
+ const sessionActivityCache = new Map();
2586
2956
  function getTranscriptRoots(agent, sandboxHome) {
2587
2957
  if (agent === 'claude') {
2588
2958
  return [(0, path_1.join)(sandboxHome, '.claude', 'projects')];
2589
2959
  }
2590
2960
  if (agent === 'codex') {
2961
+ const primaryRoots = [
2962
+ (0, path_1.join)(sandboxHome, '.codex', 'sessions'),
2963
+ (0, path_1.join)(sandboxHome, '.openai', 'codex', 'sessions'),
2964
+ (0, path_1.join)(sandboxHome, '.config', 'codex', 'sessions'),
2965
+ ];
2966
+ const existingPrimary = primaryRoots.filter((root) => (0, fs_1.existsSync)(root));
2967
+ if (existingPrimary.length > 0)
2968
+ return existingPrimary;
2591
2969
  return [
2592
2970
  (0, path_1.join)(sandboxHome, '.codex'),
2593
2971
  (0, path_1.join)(sandboxHome, '.openai', 'codex'),
@@ -2596,16 +2974,22 @@ function getTranscriptRoots(agent, sandboxHome) {
2596
2974
  }
2597
2975
  return [];
2598
2976
  }
2599
- function scanJsonlFiles(root, maxDepth = 4) {
2977
+ function scanJsonlFiles(root, maxDepth = 4, maxEntries = JSONL_SCAN_MAX_ENTRIES) {
2600
2978
  const files = [];
2601
2979
  const stack = [{ dir: root, depth: 0 }];
2980
+ let visitedEntries = 0;
2602
2981
  while (stack.length > 0) {
2982
+ if (visitedEntries >= maxEntries)
2983
+ break;
2603
2984
  const current = stack.pop();
2604
2985
  if (!current)
2605
2986
  continue;
2606
2987
  try {
2607
2988
  const entries = (0, fs_1.readdirSync)(current.dir);
2608
2989
  for (const entry of entries) {
2990
+ if (visitedEntries >= maxEntries)
2991
+ break;
2992
+ visitedEntries += 1;
2609
2993
  const fullPath = (0, path_1.join)(current.dir, entry);
2610
2994
  try {
2611
2995
  const st = (0, fs_1.statSync)(fullPath);
@@ -2632,14 +3016,14 @@ function scanJsonlFiles(root, maxDepth = 4) {
2632
3016
  }
2633
3017
  /**
2634
3018
  * Scan sandbox home for agent conversation JSONL files.
2635
- * Results are cached for 3 seconds.
3019
+ * Results are cached briefly to keep `/api/sessions` responsive.
2636
3020
  */
2637
3021
  function findProjectJsonlFiles(agent) {
2638
3022
  const normalizedAgent = (agent || '').toLowerCase();
2639
3023
  const now = Date.now();
2640
3024
  if (jsonlCache &&
2641
3025
  jsonlCache.agent === normalizedAgent &&
2642
- now - jsonlCache.ts < 2000) {
3026
+ now - jsonlCache.ts < JSONL_CACHE_TTL_MS) {
2643
3027
  return jsonlCache.files;
2644
3028
  }
2645
3029
  const files = [];
@@ -2783,7 +3167,7 @@ function scanBlockedEvents() {
2783
3167
  return events;
2784
3168
  }
2785
3169
  // ── Per-session accumulated file history (persists across tail reads) ──
2786
- /** sessionId → (path → most recent access timestamp) */
3170
+ /** sessionId → (path → { ts, action }) */
2787
3171
  const fileHistoryCache = new Map();
2788
3172
  const FILE_HISTORY_TTL = 60 * 60 * 1000; // 1 hour
2789
3173
  const websiteHistoryCache = new Map();
@@ -2798,18 +3182,16 @@ function mergeFileHistory(sessionId, newAccessed) {
2798
3182
  history = new Map();
2799
3183
  fileHistoryCache.set(sessionId, history);
2800
3184
  }
2801
- // Merge new accesses (keep newest timestamp per path)
2802
- for (const [path, ts] of newAccessed) {
2803
- if (!ts || isNaN(ts))
3185
+ // Merge new accesses (keep newest timestamp and most significant action per path)
3186
+ for (const [path, entry] of newAccessed) {
3187
+ if (!entry.ts || isNaN(entry.ts))
2804
3188
  continue;
2805
- const existing = history.get(path) || 0;
2806
- if (ts >= existing)
2807
- history.set(path, ts);
3189
+ rememberFileEntry(history, path, entry.ts, entry.action);
2808
3190
  }
2809
3191
  // Evict entries older than 1 hour
2810
3192
  const cutoff = Date.now() - FILE_HISTORY_TTL;
2811
- for (const [path, ts] of history) {
2812
- if (ts < cutoff)
3193
+ for (const [path, entry] of history) {
3194
+ if (entry.ts < cutoff)
2813
3195
  history.delete(path);
2814
3196
  }
2815
3197
  return history;
@@ -2846,7 +3228,7 @@ function getAccessedFiles(accessedFiles, workdir) {
2846
3228
  return [];
2847
3229
  const files = [];
2848
3230
  const seen = new Set();
2849
- for (const [p, ts] of accessedFiles) {
3231
+ for (const [p, entry] of accessedFiles) {
2850
3232
  const rel = p.startsWith('/work/') ? p.slice(6) : p;
2851
3233
  if (!rel || seen.has(rel))
2852
3234
  continue;
@@ -2871,8 +3253,9 @@ function getAccessedFiles(accessedFiles, workdir) {
2871
3253
  files.push({
2872
3254
  name: rel,
2873
3255
  path: p,
2874
- accessedAt: ts,
3256
+ accessedAt: entry.ts,
2875
3257
  isDir,
3258
+ action: entry.action,
2876
3259
  });
2877
3260
  }
2878
3261
  // Sort by recency (most recently accessed first)
@@ -2900,27 +3283,63 @@ function getAccessedWebsites(accessedUrls) {
2900
3283
  websites.sort((a, b) => (b.accessedAt || 0) - (a.accessedAt || 0));
2901
3284
  return websites.slice(0, 30);
2902
3285
  }
2903
- /**
2904
- * Determine an agent session's current activity by reading transcript JSONL.
2905
- * Correlates session → JSONL by matching session start time with file creation time.
2906
- */
2907
- function getAgentActivity(session) {
2908
- const emptyFiles = [];
2909
- const emptyWebsites = [];
2910
- const unknown = {
3286
+ function createUnknownActivity() {
3287
+ return {
2911
3288
  status: 'unknown',
2912
3289
  label: '',
2913
3290
  detail: '',
2914
3291
  prompt: '',
2915
3292
  since: 0,
2916
3293
  lastActive: 0,
2917
- files: emptyFiles,
2918
- websites: emptyWebsites,
3294
+ files: [],
3295
+ websites: [],
2919
3296
  };
3297
+ }
3298
+ function getSessionActivityCacheKey(session) {
3299
+ const id = String(session?.id || '').trim();
3300
+ if (!id)
3301
+ return '';
3302
+ const agent = String(session?.agent || '').trim().toLowerCase();
3303
+ const started = String(session?.started || '').trim();
3304
+ const workdir = String(session?.workdir || '').trim();
3305
+ return `${id}|${agent}|${started}|${workdir}`;
3306
+ }
3307
+ function getCachedSessionActivity(session, now = Date.now()) {
3308
+ const key = getSessionActivityCacheKey(session);
3309
+ if (!key)
3310
+ return null;
3311
+ const cached = sessionActivityCache.get(key);
3312
+ if (!cached)
3313
+ return null;
3314
+ if ((now - cached.ts) > SESSION_ACTIVITY_CACHE_TTL_MS) {
3315
+ sessionActivityCache.delete(key);
3316
+ return null;
3317
+ }
3318
+ return cached.activity;
3319
+ }
3320
+ function setCachedSessionActivity(session, activity, now = Date.now()) {
3321
+ const key = getSessionActivityCacheKey(session);
3322
+ if (!key)
3323
+ return;
3324
+ sessionActivityCache.set(key, { activity, ts: now });
3325
+ }
3326
+ function pruneSessionActivityCache(now = Date.now()) {
3327
+ for (const [key, entry] of sessionActivityCache) {
3328
+ if ((now - entry.ts) > SESSION_ACTIVITY_CACHE_TTL_MS) {
3329
+ sessionActivityCache.delete(key);
3330
+ }
3331
+ }
3332
+ }
3333
+ /**
3334
+ * Determine an agent session's current activity by reading transcript JSONL.
3335
+ * Correlates session → JSONL by matching session start time with file creation time.
3336
+ */
3337
+ function getAgentActivity(session) {
3338
+ const unknown = createUnknownActivity();
2920
3339
  const agent = String(session.agent || '').toLowerCase();
2921
3340
  const jsonlFiles = findProjectJsonlFiles(agent);
2922
3341
  if (jsonlFiles.length === 0) {
2923
- return { ...unknown, files: [], websites: [] };
3342
+ return unknown;
2924
3343
  }
2925
3344
  const sessionStartMs = session.started ? new Date(session.started).getTime() : 0;
2926
3345
  if (!sessionStartMs)
@@ -2949,43 +3368,592 @@ function getAgentActivity(session) {
2949
3368
  if (age > 60_000) {
2950
3369
  activity = { status: 'idle', label: 'Idle', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
2951
3370
  }
2952
- else if (type === 'user') {
2953
- const content = entry.message?.content;
2954
- if (Array.isArray(content) && content.some((b) => b.type === 'tool_result')) {
2955
- activity = { status: 'thinking', label: 'Processing...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
2956
- }
2957
- else {
2958
- activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
2959
- }
3371
+ else if (type === 'user') {
3372
+ const content = entry.message?.content;
3373
+ if (Array.isArray(content) && content.some((b) => b.type === 'tool_result')) {
3374
+ activity = { status: 'thinking', label: 'Processing...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
3375
+ }
3376
+ else {
3377
+ activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
3378
+ }
3379
+ }
3380
+ else if (type === 'human') {
3381
+ activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
3382
+ }
3383
+ else if (type === 'assistant') {
3384
+ const content = entry.message?.content;
3385
+ if (Array.isArray(content)) {
3386
+ const hasToolUse = content.some((b) => b.type === 'tool_use');
3387
+ if (hasToolUse) {
3388
+ const detail = extractToolDetail(entry);
3389
+ const toolBlock = content.find((b) => b.type === 'tool_use');
3390
+ const toolName = toolBlock?.name || 'tool';
3391
+ activity = { status: 'tool_use', label: `Running ${toolName}...`, detail, prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
3392
+ }
3393
+ else if (content.some((b) => b.type === 'thinking')) {
3394
+ activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
3395
+ }
3396
+ else {
3397
+ activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
3398
+ }
3399
+ }
3400
+ else {
3401
+ activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
3402
+ }
3403
+ }
3404
+ else {
3405
+ activity = { ...unknown, files, websites };
3406
+ }
3407
+ return activity;
3408
+ }
3409
+ function compactGitErrorText(value, max = 180) {
3410
+ const compact = String(value || '').replace(/\s+/g, ' ').trim();
3411
+ if (!compact)
3412
+ return 'git state unavailable';
3413
+ if (compact.length <= max)
3414
+ return compact;
3415
+ return `${compact.slice(0, max - 3)}...`;
3416
+ }
3417
+ function createSessionGitState(patch = {}) {
3418
+ const merged = {
3419
+ available: true,
3420
+ initialized: false,
3421
+ branch: null,
3422
+ detached: false,
3423
+ head: null,
3424
+ staged: 0,
3425
+ unstaged: 0,
3426
+ untracked: 0,
3427
+ conflicted: 0,
3428
+ dirty: false,
3429
+ clean: true,
3430
+ ahead: null,
3431
+ behind: null,
3432
+ checked_at: new Date().toISOString(),
3433
+ ...patch,
3434
+ };
3435
+ const dirtyCount = merged.staged + merged.unstaged + merged.untracked + merged.conflicted;
3436
+ merged.dirty = patch.dirty !== undefined ? patch.dirty === true : dirtyCount > 0;
3437
+ merged.clean = !merged.dirty;
3438
+ if (!merged.checked_at)
3439
+ merged.checked_at = new Date().toISOString();
3440
+ if (merged.error !== undefined && !merged.error)
3441
+ delete merged.error;
3442
+ return merged;
3443
+ }
3444
+ function parseSessionGitStatusPorcelain(stdout) {
3445
+ const lines = String(stdout || '').split(/\r?\n/);
3446
+ let branch = null;
3447
+ let detached = false;
3448
+ let head = null;
3449
+ let ahead = null;
3450
+ let behind = null;
3451
+ let staged = 0;
3452
+ let unstaged = 0;
3453
+ let untracked = 0;
3454
+ let conflicted = 0;
3455
+ for (const rawLine of lines) {
3456
+ const line = rawLine.trim();
3457
+ if (!line)
3458
+ continue;
3459
+ if (line.startsWith('# branch.head ')) {
3460
+ const value = line.slice('# branch.head '.length).trim();
3461
+ if (value === '(detached)') {
3462
+ detached = true;
3463
+ branch = null;
3464
+ }
3465
+ else if (value && value !== '(unknown)') {
3466
+ branch = value;
3467
+ }
3468
+ continue;
3469
+ }
3470
+ if (line.startsWith('# branch.oid ')) {
3471
+ const value = line.slice('# branch.oid '.length).trim();
3472
+ if (/^[0-9a-f]{7,40}$/i.test(value)) {
3473
+ head = value.slice(0, 12);
3474
+ }
3475
+ continue;
3476
+ }
3477
+ if (line.startsWith('# branch.ab ')) {
3478
+ const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/);
3479
+ if (match) {
3480
+ ahead = Number.parseInt(match[1], 10);
3481
+ behind = Number.parseInt(match[2], 10);
3482
+ }
3483
+ continue;
3484
+ }
3485
+ const kind = line.charAt(0);
3486
+ if (kind === '1' || kind === '2') {
3487
+ const match = line.match(/^[12] ([^ ]{2}) /);
3488
+ const xy = match?.[1] || '..';
3489
+ const x = xy.charAt(0);
3490
+ const y = xy.charAt(1);
3491
+ if (x && x !== '.')
3492
+ staged += 1;
3493
+ if (y && y !== '.')
3494
+ unstaged += 1;
3495
+ continue;
3496
+ }
3497
+ if (kind === 'u') {
3498
+ conflicted += 1;
3499
+ continue;
3500
+ }
3501
+ if (kind === '?') {
3502
+ untracked += 1;
3503
+ }
3504
+ }
3505
+ return createSessionGitState({
3506
+ available: true,
3507
+ initialized: true,
3508
+ branch,
3509
+ detached,
3510
+ head,
3511
+ staged,
3512
+ unstaged,
3513
+ untracked,
3514
+ conflicted,
3515
+ ahead,
3516
+ behind,
3517
+ });
3518
+ }
3519
+ async function collectSessionGitState(workdir) {
3520
+ const fullWorkdir = (0, path_1.resolve)(workdir);
3521
+ const now = Date.now();
3522
+ const cached = sessionGitStateCache.get(fullWorkdir);
3523
+ if (cached && (now - cached.checkedAtMs) <= SESSION_GIT_CACHE_TTL_MS) {
3524
+ return cached.state;
3525
+ }
3526
+ let state;
3527
+ try {
3528
+ const result = await execFileAsync('git', ['-C', fullWorkdir, 'status', '--porcelain=v2', '--branch'], {
3529
+ encoding: 'utf-8',
3530
+ timeout: SESSION_GIT_COMMAND_TIMEOUT_MS,
3531
+ maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
3532
+ });
3533
+ state = parseSessionGitStatusPorcelain(String(result.stdout || ''));
3534
+ }
3535
+ catch (err) {
3536
+ const code = typeof err?.code === 'string' ? err.code : '';
3537
+ const detail = `${String(err?.stderr || '')}\n${String(err?.stdout || '')}\n${String(err?.message || '')}`.trim();
3538
+ const lower = detail.toLowerCase();
3539
+ if (code === 'ENOENT' || lower.includes('spawn git enonent')) {
3540
+ state = createSessionGitState({
3541
+ available: false,
3542
+ initialized: false,
3543
+ error: 'git command not available on host',
3544
+ });
3545
+ }
3546
+ else if (lower.includes('not a git repository')) {
3547
+ state = createSessionGitState({
3548
+ available: true,
3549
+ initialized: false,
3550
+ });
3551
+ }
3552
+ else if (code === 'ETIMEDOUT' || lower.includes('timed out')) {
3553
+ state = createSessionGitState({
3554
+ available: true,
3555
+ initialized: false,
3556
+ error: 'git status timed out',
3557
+ });
3558
+ }
3559
+ else if (lower.includes('cannot change to') || lower.includes('no such file or directory')) {
3560
+ state = createSessionGitState({
3561
+ available: true,
3562
+ initialized: false,
3563
+ error: 'workdir is unavailable',
3564
+ });
3565
+ }
3566
+ else {
3567
+ state = createSessionGitState({
3568
+ available: true,
3569
+ initialized: false,
3570
+ error: compactGitErrorText(detail),
3571
+ });
3572
+ }
3573
+ }
3574
+ sessionGitStateCache.set(fullWorkdir, {
3575
+ state,
3576
+ checkedAtMs: Date.now(),
3577
+ });
3578
+ if (sessionGitStateCache.size > 512) {
3579
+ const cutoff = Date.now() - (SESSION_GIT_CACHE_TTL_MS * 4);
3580
+ for (const [pathKey, entry] of sessionGitStateCache.entries()) {
3581
+ if (entry.checkedAtMs < cutoff) {
3582
+ sessionGitStateCache.delete(pathKey);
3583
+ }
3584
+ }
3585
+ }
3586
+ return state;
3587
+ }
3588
+ async function annotateSessionsWithGitState(sessions) {
3589
+ if (!Array.isArray(sessions) || sessions.length === 0)
3590
+ return;
3591
+ const inflightByWorkdir = new Map();
3592
+ await Promise.all(sessions.map(async (session) => {
3593
+ const rawWorkdir = typeof session?.workdir === 'string' ? session.workdir.trim() : '';
3594
+ if (!rawWorkdir) {
3595
+ session.git = createSessionGitState({
3596
+ available: true,
3597
+ initialized: false,
3598
+ error: 'missing workdir',
3599
+ });
3600
+ return;
3601
+ }
3602
+ const fullWorkdir = (0, path_1.resolve)(rawWorkdir);
3603
+ let inflight = inflightByWorkdir.get(fullWorkdir);
3604
+ if (!inflight) {
3605
+ inflight = collectSessionGitState(fullWorkdir);
3606
+ inflightByWorkdir.set(fullWorkdir, inflight);
3607
+ }
3608
+ try {
3609
+ session.git = await inflight;
3610
+ }
3611
+ catch {
3612
+ session.git = createSessionGitState({
3613
+ available: true,
3614
+ initialized: false,
3615
+ error: 'git state unavailable',
3616
+ });
3617
+ }
3618
+ }));
3619
+ }
3620
+ function clearSessionGitStateCache(workdir) {
3621
+ const fullWorkdir = (0, path_1.resolve)(workdir);
3622
+ sessionGitStateCache.delete(fullWorkdir);
3623
+ }
3624
+ function parseSessionIdFromPath(pathname, pattern) {
3625
+ const match = pathname.match(pattern);
3626
+ if (!match)
3627
+ return null;
3628
+ const decoded = decodeURIComponent(match[1] || '').trim();
3629
+ const normalized = normalizeSessionId(decoded);
3630
+ if (normalized)
3631
+ return normalized;
3632
+ if ((0, web_terminal_js_1.isValidWebTerminalId)(decoded))
3633
+ return decoded;
3634
+ return null;
3635
+ }
3636
+ function resolveSessionGitWorkdir(sessionId) {
3637
+ const session = getSessionRecord(sessionId);
3638
+ if (session) {
3639
+ const localHost = (0, os_1.hostname)();
3640
+ if (session.node !== localHost) {
3641
+ return { ok: false, status: 400, error: `Session is on a different node (${session.node})` };
3642
+ }
3643
+ }
3644
+ if (session && typeof session.workdir === 'string' && session.workdir.trim()) {
3645
+ const workdir = (0, path_1.resolve)(session.workdir);
3646
+ return { ok: true, workdir };
3647
+ }
3648
+ const webSession = (0, web_terminal_js_1.readWebTerminalRecord)(sessionId);
3649
+ if (!webSession) {
3650
+ return { ok: false, status: 404, error: 'Session not found' };
3651
+ }
3652
+ if (webSession.node !== (0, os_1.hostname)()) {
3653
+ return { ok: false, status: 400, error: `Session is on a different node (${webSession.node})` };
3654
+ }
3655
+ if (typeof webSession.workdir !== 'string' || !webSession.workdir.trim()) {
3656
+ return { ok: false, status: 400, error: 'Session has no valid workdir' };
3657
+ }
3658
+ return { ok: true, workdir: (0, path_1.resolve)(webSession.workdir) };
3659
+ }
3660
+ function resolveSessionGitErrorStatus(detail) {
3661
+ const lower = String(detail || '').toLowerCase();
3662
+ if (!lower)
3663
+ return 500;
3664
+ if (lower.includes('already exists'))
3665
+ return 409;
3666
+ if (lower.includes('not a git repository'))
3667
+ return 409;
3668
+ if (lower.includes('pathspec') && lower.includes('did not match'))
3669
+ return 404;
3670
+ if (lower.includes('local changes') && lower.includes('would be overwritten'))
3671
+ return 409;
3672
+ if (lower.includes('would be overwritten by checkout'))
3673
+ return 409;
3674
+ if (lower.includes('timed out'))
3675
+ return 504;
3676
+ if (lower.includes('cannot change to') || lower.includes('no such file or directory'))
3677
+ return 400;
3678
+ return 500;
3679
+ }
3680
+ function parseGitUpstreamTrack(trackRaw) {
3681
+ const track = String(trackRaw || '');
3682
+ const aheadMatch = track.match(/ahead\s+(\d+)/i);
3683
+ const behindMatch = track.match(/behind\s+(\d+)/i);
3684
+ return {
3685
+ ahead: aheadMatch ? Number.parseInt(aheadMatch[1], 10) : 0,
3686
+ behind: behindMatch ? Number.parseInt(behindMatch[1], 10) : 0,
3687
+ };
3688
+ }
3689
+ function parseSessionGitBranchList(stdout) {
3690
+ const branches = [];
3691
+ for (const rawLine of String(stdout || '').split(/\r?\n/)) {
3692
+ if (!rawLine)
3693
+ continue;
3694
+ const parts = rawLine.split('\t');
3695
+ const name = String(parts[0] || '').trim();
3696
+ if (!name)
3697
+ continue;
3698
+ const current = String(parts[1] || '').trim() === '*';
3699
+ const upstreamText = String(parts[2] || '').trim();
3700
+ const { ahead, behind } = parseGitUpstreamTrack(String(parts[3] || '').trim());
3701
+ branches.push({
3702
+ name,
3703
+ current,
3704
+ upstream: upstreamText || null,
3705
+ ahead,
3706
+ behind,
3707
+ });
3708
+ }
3709
+ return branches;
3710
+ }
3711
+ async function collectSessionGitBranches(workdir) {
3712
+ const result = await execFileAsync('git', [
3713
+ '-C',
3714
+ workdir,
3715
+ 'for-each-ref',
3716
+ '--sort=refname',
3717
+ '--format=%(refname:short)%09%(HEAD)%09%(upstream:short)%09%(upstream:track)',
3718
+ 'refs/heads',
3719
+ ], {
3720
+ encoding: 'utf-8',
3721
+ timeout: SESSION_GIT_MUTATION_TIMEOUT_MS,
3722
+ maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
3723
+ });
3724
+ return parseSessionGitBranchList(String(result.stdout || ''));
3725
+ }
3726
+ async function ensureSessionGitReady(workdir) {
3727
+ const git = await collectSessionGitState(workdir);
3728
+ if (git.available === false) {
3729
+ return {
3730
+ ok: false,
3731
+ status: 503,
3732
+ error: git.error || 'git command not available on host',
3733
+ git,
3734
+ };
3735
+ }
3736
+ if (!git.initialized) {
3737
+ return {
3738
+ ok: false,
3739
+ status: 409,
3740
+ error: git.error || 'No git repository initialized in this workdir',
3741
+ git,
3742
+ };
3743
+ }
3744
+ return { ok: true, git };
3745
+ }
3746
+ async function handleGetSessionGitBranches(pathname, res) {
3747
+ const sessionId = parseSessionIdFromPath(pathname, /^\/api\/sessions\/([^/]+)\/git\/branches$/);
3748
+ if (!sessionId) {
3749
+ json(res, { ok: false, error: 'Invalid session id' }, 400);
3750
+ return;
3751
+ }
3752
+ const resolved = resolveSessionGitWorkdir(sessionId);
3753
+ if (!resolved.ok) {
3754
+ json(res, { ok: false, error: resolved.error }, resolved.status);
3755
+ return;
3756
+ }
3757
+ const ready = await ensureSessionGitReady(resolved.workdir);
3758
+ if (!ready.ok) {
3759
+ json(res, { ok: false, error: ready.error, git: ready.git }, ready.status);
3760
+ return;
3761
+ }
3762
+ try {
3763
+ const branches = await collectSessionGitBranches(resolved.workdir);
3764
+ json(res, {
3765
+ ok: true,
3766
+ sessionId,
3767
+ workdir: resolved.workdir,
3768
+ git: ready.git,
3769
+ branches,
3770
+ });
3771
+ }
3772
+ catch (err) {
3773
+ const detail = compactGitErrorText(commandErrorDetail(err) || (err?.message ?? String(err)), 220);
3774
+ json(res, { ok: false, error: detail }, resolveSessionGitErrorStatus(detail));
3775
+ }
3776
+ }
3777
+ async function handleGetSessionGitDag(pathname, reqUrl, res) {
3778
+ const sessionId = parseSessionIdFromPath(pathname, /^\/api\/sessions\/([^/]+)\/git\/dag$/);
3779
+ if (!sessionId) {
3780
+ json(res, { ok: false, error: 'Invalid session id' }, 400);
3781
+ return;
3782
+ }
3783
+ let limit = 120;
3784
+ const limitRaw = String(reqUrl.searchParams.get('limit') || '').trim();
3785
+ if (limitRaw) {
3786
+ const parsed = Number(limitRaw);
3787
+ if (!Number.isFinite(parsed)) {
3788
+ json(res, { ok: false, error: 'Invalid limit' }, 400);
3789
+ return;
3790
+ }
3791
+ limit = Math.max(20, Math.min(400, Math.floor(parsed)));
3792
+ }
3793
+ const resolved = resolveSessionGitWorkdir(sessionId);
3794
+ if (!resolved.ok) {
3795
+ json(res, { ok: false, error: resolved.error }, resolved.status);
3796
+ return;
3797
+ }
3798
+ const ready = await ensureSessionGitReady(resolved.workdir);
3799
+ if (!ready.ok) {
3800
+ json(res, { ok: false, error: ready.error, git: ready.git }, ready.status);
3801
+ return;
3802
+ }
3803
+ try {
3804
+ const result = await execFileAsync('git', [
3805
+ '-C',
3806
+ resolved.workdir,
3807
+ 'log',
3808
+ '--graph',
3809
+ '--decorate',
3810
+ '--oneline',
3811
+ '--all',
3812
+ `--max-count=${limit}`,
3813
+ '--color=never',
3814
+ ], {
3815
+ encoding: 'utf-8',
3816
+ timeout: SESSION_GIT_MUTATION_TIMEOUT_MS,
3817
+ maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
3818
+ });
3819
+ const graph = String(result.stdout || '').replace(/\s+$/u, '');
3820
+ const lines = graph ? graph.split(/\r?\n/) : [];
3821
+ json(res, {
3822
+ ok: true,
3823
+ sessionId,
3824
+ workdir: resolved.workdir,
3825
+ git: ready.git,
3826
+ graph,
3827
+ lineCount: lines.length,
3828
+ truncated: lines.length >= limit,
3829
+ limit,
3830
+ });
3831
+ }
3832
+ catch (err) {
3833
+ const detail = compactGitErrorText(commandErrorDetail(err) || (err?.message ?? String(err)), 220);
3834
+ const lower = detail.toLowerCase();
3835
+ if (lower.includes('does not have any commits yet')) {
3836
+ json(res, {
3837
+ ok: true,
3838
+ sessionId,
3839
+ workdir: resolved.workdir,
3840
+ git: ready.git,
3841
+ graph: '',
3842
+ lineCount: 0,
3843
+ truncated: false,
3844
+ limit,
3845
+ });
3846
+ return;
3847
+ }
3848
+ json(res, { ok: false, error: detail }, resolveSessionGitErrorStatus(detail));
3849
+ }
3850
+ }
3851
+ async function handlePostSessionGitCheckout(pathname, req, res) {
3852
+ const sessionId = parseSessionIdFromPath(pathname, /^\/api\/sessions\/([^/]+)\/git\/checkout$/);
3853
+ if (!sessionId) {
3854
+ json(res, { ok: false, error: 'Invalid session id' }, 400);
3855
+ return;
3856
+ }
3857
+ const resolved = resolveSessionGitWorkdir(sessionId);
3858
+ if (!resolved.ok) {
3859
+ json(res, { ok: false, error: resolved.error }, resolved.status);
3860
+ return;
3861
+ }
3862
+ let parsed;
3863
+ try {
3864
+ parsed = JSON.parse(await readBody(req));
3865
+ }
3866
+ catch {
3867
+ json(res, { ok: false, error: 'Invalid JSON' }, 400);
3868
+ return;
3869
+ }
3870
+ const branch = String(parsed.branch || '').trim();
3871
+ if (!branch) {
3872
+ json(res, { ok: false, error: 'branch is required' }, 400);
3873
+ return;
3874
+ }
3875
+ const ready = await ensureSessionGitReady(resolved.workdir);
3876
+ if (!ready.ok) {
3877
+ json(res, { ok: false, error: ready.error, git: ready.git }, ready.status);
3878
+ return;
3879
+ }
3880
+ if (!ready.git.detached && ready.git.branch === branch) {
3881
+ json(res, { ok: true, sessionId, unchanged: true, git: ready.git });
3882
+ return;
3883
+ }
3884
+ try {
3885
+ await execFileAsync('git', ['-C', resolved.workdir, 'checkout', branch], {
3886
+ encoding: 'utf-8',
3887
+ timeout: SESSION_GIT_MUTATION_TIMEOUT_MS,
3888
+ maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
3889
+ });
3890
+ clearSessionGitStateCache(resolved.workdir);
3891
+ const git = await collectSessionGitState(resolved.workdir);
3892
+ json(res, { ok: true, sessionId, git });
3893
+ }
3894
+ catch (err) {
3895
+ const detail = compactGitErrorText(commandErrorDetail(err) || (err?.message ?? String(err)), 220);
3896
+ json(res, { ok: false, error: detail }, resolveSessionGitErrorStatus(detail));
3897
+ }
3898
+ }
3899
+ async function handlePostSessionGitBranchCreate(pathname, req, res) {
3900
+ const sessionId = parseSessionIdFromPath(pathname, /^\/api\/sessions\/([^/]+)\/git\/branches$/);
3901
+ if (!sessionId) {
3902
+ json(res, { ok: false, error: 'Invalid session id' }, 400);
3903
+ return;
3904
+ }
3905
+ const resolved = resolveSessionGitWorkdir(sessionId);
3906
+ if (!resolved.ok) {
3907
+ json(res, { ok: false, error: resolved.error }, resolved.status);
3908
+ return;
3909
+ }
3910
+ let parsed;
3911
+ try {
3912
+ parsed = JSON.parse(await readBody(req));
3913
+ }
3914
+ catch {
3915
+ json(res, { ok: false, error: 'Invalid JSON' }, 400);
3916
+ return;
2960
3917
  }
2961
- else if (type === 'human') {
2962
- activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
3918
+ const name = String(parsed.name || '').trim();
3919
+ const from = String(parsed.from || '').trim();
3920
+ if (!name) {
3921
+ json(res, { ok: false, error: 'name is required' }, 400);
3922
+ return;
2963
3923
  }
2964
- else if (type === 'assistant') {
2965
- const content = entry.message?.content;
2966
- if (Array.isArray(content)) {
2967
- const hasToolUse = content.some((b) => b.type === 'tool_use');
2968
- if (hasToolUse) {
2969
- const detail = extractToolDetail(entry);
2970
- const toolBlock = content.find((b) => b.type === 'tool_use');
2971
- const toolName = toolBlock?.name || 'tool';
2972
- activity = { status: 'tool_use', label: `Running ${toolName}...`, detail, prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
2973
- }
2974
- else if (content.some((b) => b.type === 'thinking')) {
2975
- activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
2976
- }
2977
- else {
2978
- activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
2979
- }
2980
- }
2981
- else {
2982
- activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
2983
- }
3924
+ const ready = await ensureSessionGitReady(resolved.workdir);
3925
+ if (!ready.ok) {
3926
+ json(res, { ok: false, error: ready.error, git: ready.git }, ready.status);
3927
+ return;
2984
3928
  }
2985
- else {
2986
- activity = { ...unknown, files, websites };
3929
+ try {
3930
+ await execFileAsync('git', ['check-ref-format', '--branch', name], {
3931
+ encoding: 'utf-8',
3932
+ timeout: SESSION_GIT_COMMAND_TIMEOUT_MS,
3933
+ maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
3934
+ });
3935
+ }
3936
+ catch {
3937
+ json(res, { ok: false, error: 'Invalid git branch name' }, 400);
3938
+ return;
3939
+ }
3940
+ try {
3941
+ const args = ['-C', resolved.workdir, 'checkout', '-b', name];
3942
+ if (from)
3943
+ args.push(from);
3944
+ await execFileAsync('git', args, {
3945
+ encoding: 'utf-8',
3946
+ timeout: SESSION_GIT_MUTATION_TIMEOUT_MS,
3947
+ maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
3948
+ });
3949
+ clearSessionGitStateCache(resolved.workdir);
3950
+ const git = await collectSessionGitState(resolved.workdir);
3951
+ json(res, { ok: true, sessionId, created: name, git });
3952
+ }
3953
+ catch (err) {
3954
+ const detail = compactGitErrorText(commandErrorDetail(err) || (err?.message ?? String(err)), 220);
3955
+ json(res, { ok: false, error: detail }, resolveSessionGitErrorStatus(detail));
2987
3956
  }
2988
- return activity;
2989
3957
  }
2990
3958
  function getSessionStatus(workdir) {
2991
3959
  try {
@@ -3007,6 +3975,8 @@ async function handleGetSessions(_req, res) {
3007
3975
  const localHost = (0, os_1.hostname)();
3008
3976
  const files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'));
3009
3977
  const sessions = [];
3978
+ const now = Date.now();
3979
+ pruneSessionActivityCache(now);
3010
3980
  for (const file of files) {
3011
3981
  try {
3012
3982
  const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
@@ -3025,11 +3995,19 @@ async function handleGetSessions(_req, res) {
3025
3995
  }
3026
3996
  }
3027
3997
  data.status = getSessionStatus(data.workdir);
3028
- data.activity = getAgentActivity(data);
3998
+ const cachedActivity = getCachedSessionActivity(data, now);
3999
+ if (cachedActivity) {
4000
+ data.activity = cachedActivity;
4001
+ }
4002
+ else {
4003
+ data.activity = getAgentActivity(data);
4004
+ setCachedSessionActivity(data, data.activity, now);
4005
+ }
3029
4006
  sessions.push(data);
3030
4007
  }
3031
4008
  catch { /* skip unparseable files */ }
3032
4009
  }
4010
+ await annotateSessionsWithGitState(sessions);
3033
4011
  // Collect container resource stats
3034
4012
  const sessionIds = sessions.map((s) => s.id).filter(Boolean);
3035
4013
  try {
@@ -3223,11 +4201,16 @@ async function handlePostWebTerminalStart(req, res) {
3223
4201
  const body = await readBody(req);
3224
4202
  const parsed = JSON.parse(body || '{}');
3225
4203
  const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
4204
+ const permissionMode = normalizeWebTerminalPermissionMode(parsed.permission_mode);
3226
4205
  const rawWorkdir = String(parsed.workdir || '').trim();
3227
4206
  if (!agent) {
3228
4207
  json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
3229
4208
  return;
3230
4209
  }
4210
+ if (!permissionMode) {
4211
+ json(res, { ok: false, error: 'permission_mode must be "default" or "dangerous"' }, 400);
4212
+ return;
4213
+ }
3231
4214
  if (!rawWorkdir) {
3232
4215
  json(res, { ok: false, error: 'workdir is required' }, 400);
3233
4216
  return;
@@ -3249,7 +4232,7 @@ async function handlePostWebTerminalStart(req, res) {
3249
4232
  json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
3250
4233
  return;
3251
4234
  }
3252
- const result = await startWebTerminalSession(agent, resolvedWorkdir);
4235
+ const result = await startWebTerminalSession(agent, resolvedWorkdir, { permissionMode });
3253
4236
  if (!result.ok) {
3254
4237
  json(res, result.body, result.status);
3255
4238
  return;
@@ -3275,6 +4258,7 @@ async function runWebTerminalInitJob(id) {
3275
4258
  const result = await startWebTerminalSession(job.agent, job.workdir, {
3276
4259
  prewarmImage: true,
3277
4260
  prewarmAgent: true,
4261
+ permissionMode: job.permissionMode,
3278
4262
  onProgress: progress,
3279
4263
  });
3280
4264
  if (result.ok) {
@@ -3325,11 +4309,16 @@ async function handlePostWebTerminalInit(req, res) {
3325
4309
  const body = await readBody(req);
3326
4310
  const parsed = JSON.parse(body || '{}');
3327
4311
  const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
4312
+ const permissionMode = normalizeWebTerminalPermissionMode(parsed.permission_mode);
3328
4313
  const rawWorkdir = String(parsed.workdir || '').trim();
3329
4314
  if (!agent) {
3330
4315
  json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
3331
4316
  return;
3332
4317
  }
4318
+ if (!permissionMode) {
4319
+ json(res, { ok: false, error: 'permission_mode must be "default" or "dangerous"' }, 400);
4320
+ return;
4321
+ }
3333
4322
  if (!rawWorkdir) {
3334
4323
  json(res, { ok: false, error: 'workdir is required' }, 400);
3335
4324
  return;
@@ -3352,7 +4341,7 @@ async function handlePostWebTerminalInit(req, res) {
3352
4341
  return;
3353
4342
  }
3354
4343
  pruneWebTerminalInitJobs();
3355
- const job = createWebTerminalInitJob(agent, resolvedWorkdir);
4344
+ const job = createWebTerminalInitJob(agent, resolvedWorkdir, permissionMode);
3356
4345
  webTerminalInitJobs.set(job.id, job);
3357
4346
  void runWebTerminalInitJob(job.id);
3358
4347
  json(res, { ok: true, init: serializeWebTerminalInitJob(job) }, 202);
@@ -3455,11 +4444,36 @@ async function handleGetWebTerminalInit(reqUrl, res) {
3455
4444
  async function handleGetWebTerminalSessions(res) {
3456
4445
  const records = (0, web_terminal_js_1.listWebTerminalRecords)();
3457
4446
  const localNode = (0, os_1.hostname)();
3458
- for (const record of records) {
4447
+ const now = Date.now();
4448
+ const TMUX_STATUS_PROBE_MAX = 16;
4449
+ const TMUX_RECENT_NON_RUNNING_PROBE_MS = 45_000;
4450
+ function parseRecordTimeMs(record) {
4451
+ const raw = String(record.updatedAt || record.createdAt || '').trim();
4452
+ if (!raw)
4453
+ return 0;
4454
+ const ms = Date.parse(raw);
4455
+ return Number.isFinite(ms) ? ms : 0;
4456
+ }
4457
+ const probeCandidates = records
4458
+ .filter((record) => {
4459
+ if (record.node !== localNode)
4460
+ return false;
4461
+ if (record.status === 'running')
4462
+ return true;
4463
+ const ageMs = now - parseRecordTimeMs(record);
4464
+ return ageMs >= 0 && ageMs <= TMUX_RECENT_NON_RUNNING_PROBE_MS;
4465
+ })
4466
+ .sort((a, b) => {
4467
+ const aRunning = a.status === 'running' ? 0 : 1;
4468
+ const bRunning = b.status === 'running' ? 0 : 1;
4469
+ if (aRunning !== bRunning)
4470
+ return aRunning - bRunning;
4471
+ return parseRecordTimeMs(b) - parseRecordTimeMs(a);
4472
+ })
4473
+ .slice(0, TMUX_STATUS_PROBE_MAX);
4474
+ for (const record of probeCandidates) {
3459
4475
  // Shared home directories may contain records from other nodes.
3460
4476
  // Only mutate runtime status for sessions that belong to this node.
3461
- if (record.node !== localNode)
3462
- continue;
3463
4477
  const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
3464
4478
  if (alive && record.status !== 'running') {
3465
4479
  (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, { status: 'running', exitCode: null, error: null });
@@ -3475,8 +4489,25 @@ async function handleGetWebTerminalSessions(res) {
3475
4489
  });
3476
4490
  }
3477
4491
  }
3478
- const sessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
3479
- json(res, { ok: true, sessions });
4492
+ function parseSessionSortMs(session) {
4493
+ const raw = String(session.updatedAt || session.createdAt || '').trim();
4494
+ if (!raw)
4495
+ return 0;
4496
+ const ms = Date.parse(raw);
4497
+ return Number.isFinite(ms) ? ms : 0;
4498
+ }
4499
+ const allSessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
4500
+ const activeSessions = allSessions.filter((session) => session.status === 'running');
4501
+ const recentInactive = allSessions
4502
+ .filter((session) => session.status !== 'running')
4503
+ .sort((a, b) => parseSessionSortMs(b) - parseSessionSortMs(a))
4504
+ .slice(0, 24);
4505
+ const sessions = activeSessions.concat(recentInactive);
4506
+ pruneWebTerminalInitJobs();
4507
+ const initJobs = Array.from(webTerminalInitJobs.values())
4508
+ .filter((job) => job.status === 'running')
4509
+ .map((job) => serializeWebTerminalInitJob(job));
4510
+ json(res, { ok: true, sessions, initJobs });
3480
4511
  }
3481
4512
  async function handleGetWebTerminalHistory(reqUrl, res) {
3482
4513
  const id = String(reqUrl.searchParams.get('id') || '').trim();
@@ -3627,6 +4658,95 @@ async function handleValidatePath(req, res) {
3627
4658
  }
3628
4659
  }
3629
4660
  // ── Directory browse helper ─────────────────────────────
4661
+ const BROWSE_DIR_GIT_BADGE_PRIORITY = {
4662
+ '!': 6,
4663
+ M: 5,
4664
+ R: 4,
4665
+ A: 3,
4666
+ D: 2,
4667
+ '?': 1,
4668
+ };
4669
+ function resolveBrowseDirGitBadgeFromPorcelain(kind, xy) {
4670
+ const x = String(xy || '..').charAt(0);
4671
+ const y = String(xy || '..').charAt(1);
4672
+ if (kind === 'u' || x === 'U' || y === 'U')
4673
+ return '!';
4674
+ if (kind === '?')
4675
+ return '?';
4676
+ if (x === 'M' || y === 'M' || x === 'T' || y === 'T' || x === 'C' || y === 'C')
4677
+ return 'M';
4678
+ if (x === 'R' || y === 'R')
4679
+ return 'R';
4680
+ if (x === 'A' || y === 'A')
4681
+ return 'A';
4682
+ if (x === 'D' || y === 'D')
4683
+ return 'D';
4684
+ return '';
4685
+ }
4686
+ function pickBrowseDirGitBadge(current, next) {
4687
+ if (!current)
4688
+ return next;
4689
+ const currentPriority = BROWSE_DIR_GIT_BADGE_PRIORITY[current] || 0;
4690
+ const nextPriority = BROWSE_DIR_GIT_BADGE_PRIORITY[next] || 0;
4691
+ return nextPriority > currentPriority ? next : current;
4692
+ }
4693
+ async function collectBrowseDirGitStatus(resolvedDir) {
4694
+ const statusByName = new Map();
4695
+ let stdout = '';
4696
+ try {
4697
+ const result = await execFileAsync('git', ['-C', resolvedDir, 'status', '--porcelain=v2', '--', '.'], {
4698
+ encoding: 'utf-8',
4699
+ timeout: BROWSE_DIR_GIT_STATUS_TIMEOUT_MS,
4700
+ maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
4701
+ });
4702
+ stdout = String(result.stdout || '');
4703
+ }
4704
+ catch {
4705
+ return statusByName;
4706
+ }
4707
+ const lines = stdout.split(/\r?\n/);
4708
+ for (const rawLine of lines) {
4709
+ const line = rawLine.trimEnd();
4710
+ if (!line || line.startsWith('#'))
4711
+ continue;
4712
+ const kind = line.charAt(0);
4713
+ let xy = '..';
4714
+ let relativePath = '';
4715
+ if (kind === '?') {
4716
+ relativePath = line.slice(2).trim();
4717
+ xy = '??';
4718
+ }
4719
+ else if (kind === '1' || kind === '2' || kind === 'u') {
4720
+ const tabParts = line.split('\t');
4721
+ if (tabParts.length < 2)
4722
+ continue;
4723
+ const metaParts = tabParts[0].split(' ');
4724
+ if (metaParts.length >= 2)
4725
+ xy = String(metaParts[1] || '..');
4726
+ relativePath = String(tabParts[1] || '').trim();
4727
+ }
4728
+ else {
4729
+ continue;
4730
+ }
4731
+ if (!relativePath)
4732
+ continue;
4733
+ const normalized = relativePath
4734
+ .replace(/\\/g, '/')
4735
+ .replace(/^\.\/+/, '')
4736
+ .replace(/^"+|"+$/g, '');
4737
+ if (!normalized)
4738
+ continue;
4739
+ const firstSegment = normalized.split('/')[0];
4740
+ if (!firstSegment)
4741
+ continue;
4742
+ const badge = resolveBrowseDirGitBadgeFromPorcelain(kind, xy);
4743
+ if (!badge)
4744
+ continue;
4745
+ const existing = statusByName.get(firstSegment);
4746
+ statusByName.set(firstSegment, pickBrowseDirGitBadge(existing, badge));
4747
+ }
4748
+ return statusByName;
4749
+ }
3630
4750
  async function handleBrowseDir(req, res) {
3631
4751
  try {
3632
4752
  const body = await readBody(req);
@@ -3634,6 +4754,7 @@ async function handleBrowseDir(req, res) {
3634
4754
  const rawPath = typeof parsed.path === 'string' ? parsed.path : '~';
3635
4755
  const includeFiles = !!parsed.includeFiles;
3636
4756
  const includeHidden = !!parsed.includeHidden;
4757
+ const includeGitStatus = !!parsed.includeGitStatus;
3637
4758
  const rawLimit = Number(parsed.maxEntries);
3638
4759
  const maxEntries = Number.isFinite(rawLimit)
3639
4760
  ? Math.max(100, Math.min(5000, Math.floor(rawLimit)))
@@ -3661,10 +4782,10 @@ async function handleBrowseDir(req, res) {
3661
4782
  try {
3662
4783
  const st = (0, fs_1.statSync)(full);
3663
4784
  if (st.isDirectory()) {
3664
- dirs.push({ name: entry, path: full });
4785
+ dirs.push({ name: entry, path: full, mtime_ms: Math.round(st.mtimeMs) });
3665
4786
  }
3666
4787
  else if (includeFiles && st.isFile()) {
3667
- files.push({ name: entry, path: full });
4788
+ files.push({ name: entry, path: full, mtime_ms: Math.round(st.mtimeMs) });
3668
4789
  }
3669
4790
  }
3670
4791
  catch { /* skip inaccessible */ }
@@ -3675,15 +4796,24 @@ async function handleBrowseDir(req, res) {
3675
4796
  }
3676
4797
  dirs.sort((a, b) => a.name.localeCompare(b.name));
3677
4798
  files.sort((a, b) => a.name.localeCompare(b.name));
4799
+ const gitStatusByName = includeGitStatus ? await collectBrowseDirGitStatus(resolved) : new Map();
4800
+ const dirsOut = dirs.map((dir) => {
4801
+ const gitStatus = gitStatusByName.get(dir.name);
4802
+ return gitStatus ? { ...dir, git_status: gitStatus } : dir;
4803
+ });
4804
+ const filesOut = files.map((file) => {
4805
+ const gitStatus = gitStatusByName.get(file.name);
4806
+ return gitStatus ? { ...file, git_status: gitStatus } : file;
4807
+ });
3678
4808
  const entriesOut = [
3679
- ...dirs.map((d) => ({ name: d.name, path: d.path, type: 'dir' })),
3680
- ...files.map((f) => ({ name: f.name, path: f.path, type: 'file' })),
4809
+ ...dirsOut.map((d) => ({ name: d.name, path: d.path, type: 'dir', mtime_ms: d.mtime_ms, git_status: d.git_status })),
4810
+ ...filesOut.map((f) => ({ name: f.name, path: f.path, type: 'file', mtime_ms: f.mtime_ms, git_status: f.git_status })),
3681
4811
  ];
3682
4812
  json(res, {
3683
4813
  ok: true,
3684
4814
  path: resolved,
3685
- dirs,
3686
- files: includeFiles ? files : undefined,
4815
+ dirs: dirsOut,
4816
+ files: includeFiles ? filesOut : undefined,
3687
4817
  entries: includeFiles ? entriesOut : undefined,
3688
4818
  truncated,
3689
4819
  });
@@ -3692,6 +4822,136 @@ async function handleBrowseDir(req, res) {
3692
4822
  json(res, { ok: false, error: err.message ?? String(err) }, 400);
3693
4823
  }
3694
4824
  }
4825
+ function collectFilePreviewAllowedRoots() {
4826
+ const roots = new Set();
4827
+ const addRoot = (rawPath) => {
4828
+ if (typeof rawPath !== 'string')
4829
+ return;
4830
+ const trimmed = rawPath.trim();
4831
+ if (!trimmed)
4832
+ return;
4833
+ const resolved = trimmed.replace(/^~/, (0, os_1.homedir)());
4834
+ try {
4835
+ const real = (0, fs_1.realpathSync)(resolved);
4836
+ if (!(0, fs_1.statSync)(real).isDirectory())
4837
+ return;
4838
+ roots.add(real);
4839
+ }
4840
+ catch {
4841
+ // Ignore missing/inaccessible roots.
4842
+ }
4843
+ };
4844
+ try {
4845
+ for (const record of (0, web_terminal_js_1.listWebTerminalRecords)()) {
4846
+ addRoot(record.workdir);
4847
+ }
4848
+ }
4849
+ catch {
4850
+ // Best-effort root discovery.
4851
+ }
4852
+ const config = (0, config_js_1.loadConfig)();
4853
+ const extraPaths = Array.isArray(config.filesystem?.extra_paths) ? config.filesystem.extra_paths : [];
4854
+ for (const entry of extraPaths) {
4855
+ if (entry && typeof entry === 'object') {
4856
+ addRoot(entry.path);
4857
+ }
4858
+ }
4859
+ return [...roots];
4860
+ }
4861
+ function isPathWithinAllowedRoot(filePath, rootPath) {
4862
+ const rel = (0, path_1.relative)(rootPath, filePath);
4863
+ if (!rel)
4864
+ return true;
4865
+ return !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel);
4866
+ }
4867
+ function handleReadWorkspaceFile(reqUrl, res) {
4868
+ const rawPath = String(reqUrl.searchParams.get('path') || '').trim();
4869
+ if (!rawPath) {
4870
+ json(res, { ok: false, error: 'Missing path parameter' }, 400);
4871
+ return;
4872
+ }
4873
+ if (rawPath.includes('\0')) {
4874
+ json(res, { ok: false, error: 'Invalid path' }, 400);
4875
+ return;
4876
+ }
4877
+ const requestedMaxBytes = Number(reqUrl.searchParams.get('max_bytes'));
4878
+ const maxBytes = Number.isFinite(requestedMaxBytes)
4879
+ ? Math.max(4096, Math.min(FILE_PREVIEW_MAX_BYTES_LIMIT, Math.floor(requestedMaxBytes)))
4880
+ : FILE_PREVIEW_DEFAULT_MAX_BYTES;
4881
+ const resolved = rawPath.replace(/^~/, (0, os_1.homedir)());
4882
+ let realFilePath;
4883
+ try {
4884
+ realFilePath = (0, fs_1.realpathSync)(resolved);
4885
+ }
4886
+ catch {
4887
+ json(res, { ok: false, error: 'File not found' }, 404);
4888
+ return;
4889
+ }
4890
+ let fileStat;
4891
+ try {
4892
+ fileStat = (0, fs_1.statSync)(realFilePath);
4893
+ }
4894
+ catch {
4895
+ json(res, { ok: false, error: 'File not found' }, 404);
4896
+ return;
4897
+ }
4898
+ if (!fileStat.isFile()) {
4899
+ json(res, { ok: false, error: 'Path is not a file' }, 400);
4900
+ return;
4901
+ }
4902
+ const allowedRoots = collectFilePreviewAllowedRoots();
4903
+ if (allowedRoots.length === 0) {
4904
+ json(res, { ok: false, error: 'No allowed file roots are available' }, 403);
4905
+ return;
4906
+ }
4907
+ if (!allowedRoots.some((rootPath) => isPathWithinAllowedRoot(realFilePath, rootPath))) {
4908
+ json(res, { ok: false, error: 'Path is outside allowed file roots' }, 403);
4909
+ return;
4910
+ }
4911
+ const readBytes = Math.min(fileStat.size, maxBytes + 1);
4912
+ let fd;
4913
+ try {
4914
+ fd = (0, fs_1.openSync)(realFilePath, 'r');
4915
+ }
4916
+ catch {
4917
+ json(res, { ok: false, error: 'Failed to open file' }, 500);
4918
+ return;
4919
+ }
4920
+ const buffer = Buffer.alloc(readBytes);
4921
+ let bytesRead = 0;
4922
+ try {
4923
+ bytesRead = (0, fs_1.readSync)(fd, buffer, 0, readBytes, 0);
4924
+ }
4925
+ catch {
4926
+ json(res, { ok: false, error: 'Failed to read file' }, 500);
4927
+ return;
4928
+ }
4929
+ finally {
4930
+ try {
4931
+ (0, fs_1.closeSync)(fd);
4932
+ }
4933
+ catch {
4934
+ // noop
4935
+ }
4936
+ }
4937
+ const payload = buffer.subarray(0, bytesRead);
4938
+ const binaryScan = payload.subarray(0, Math.min(payload.length, FILE_PREVIEW_BINARY_SCAN_BYTES));
4939
+ if (binaryScan.includes(0)) {
4940
+ json(res, { ok: false, error: 'Binary file preview is not supported', code: 'binary_file' }, 415);
4941
+ return;
4942
+ }
4943
+ const truncated = fileStat.size > maxBytes;
4944
+ const contentBuffer = truncated ? payload.subarray(0, Math.min(payload.length, maxBytes)) : payload;
4945
+ const content = contentBuffer.toString('utf-8');
4946
+ json(res, {
4947
+ ok: true,
4948
+ path: realFilePath,
4949
+ size: fileStat.size,
4950
+ truncated,
4951
+ max_bytes: maxBytes,
4952
+ content,
4953
+ });
4954
+ }
3695
4955
  // ── Dataset stats helpers ───────────────────────────────
3696
4956
  function formatBytesUI(bytes) {
3697
4957
  if (bytes === 0)
@@ -3938,6 +5198,78 @@ async function handlePostDatasetExampleInstall(req, res) {
3938
5198
  json(res, { ok: false, error: err?.message ?? String(err) }, 500);
3939
5199
  }
3940
5200
  }
5201
+ function readRecentAuditLogEntries(maxEntries = 50) {
5202
+ const config = (0, config_js_1.loadConfig)();
5203
+ if (!config.audit.enabled)
5204
+ return [];
5205
+ const logDir = (0, config_js_1.getLogDir)(config);
5206
+ if (!(0, fs_1.existsSync)(logDir))
5207
+ return [];
5208
+ const files = (0, fs_1.readdirSync)(logDir)
5209
+ .filter((file) => file.endsWith('.jsonl'))
5210
+ .sort()
5211
+ .reverse();
5212
+ const entries = [];
5213
+ for (const file of files) {
5214
+ if (entries.length >= maxEntries)
5215
+ break;
5216
+ const filePath = (0, path_1.resolve)(logDir, file);
5217
+ try {
5218
+ const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
5219
+ const lines = content.split('\n').filter((line) => line.trim().length > 0);
5220
+ for (let idx = lines.length - 1; idx >= 0; idx -= 1) {
5221
+ if (entries.length >= maxEntries)
5222
+ break;
5223
+ try {
5224
+ const parsed = JSON.parse(lines[idx]);
5225
+ if (parsed && typeof parsed === 'object') {
5226
+ entries.push(parsed);
5227
+ }
5228
+ }
5229
+ catch {
5230
+ // Skip malformed log lines.
5231
+ }
5232
+ }
5233
+ }
5234
+ catch {
5235
+ // Skip unreadable files.
5236
+ }
5237
+ }
5238
+ return entries;
5239
+ }
5240
+ function buildFeedbackDiagnostics() {
5241
+ const config = (0, config_js_1.loadConfig)();
5242
+ let username = null;
5243
+ try {
5244
+ username = (0, os_1.userInfo)().username;
5245
+ }
5246
+ catch {
5247
+ username = null;
5248
+ }
5249
+ const logDir = (0, config_js_1.getLogDir)(config);
5250
+ return {
5251
+ collected_at: new Date().toISOString(),
5252
+ app: {
5253
+ name: 'labgate-ui',
5254
+ version: readPackageVersion(),
5255
+ },
5256
+ host: {
5257
+ platform: (0, os_1.platform)(),
5258
+ hostname: (0, os_1.hostname)(),
5259
+ node_version: process.version,
5260
+ user: username,
5261
+ },
5262
+ config: {
5263
+ runtime: config.runtime,
5264
+ network_mode: config.network?.mode ?? null,
5265
+ slurm_enabled: !!config.slurm?.enabled,
5266
+ audit_enabled: !!config.audit?.enabled,
5267
+ log_dir: logDir,
5268
+ },
5269
+ slurm_runtime: getSlurmRuntimeStatus(),
5270
+ audit_logs: readRecentAuditLogEntries(80),
5271
+ };
5272
+ }
3941
5273
  function handleGetLogs(_req, res) {
3942
5274
  const config = (0, config_js_1.loadConfig)();
3943
5275
  if (!config.audit.enabled) {
@@ -3949,36 +5281,51 @@ function handleGetLogs(_req, res) {
3949
5281
  json(res, { entries: [], message: 'No audit logs found yet' });
3950
5282
  return;
3951
5283
  }
3952
- const files = (0, fs_1.readdirSync)(logDir)
3953
- .filter((f) => f.endsWith('.jsonl'))
3954
- .sort()
3955
- .reverse();
3956
- if (files.length === 0) {
3957
- json(res, { entries: [], message: 'No audit logs found yet' });
5284
+ const entries = readRecentAuditLogEntries(50);
5285
+ if (entries.length === 0) {
5286
+ json(res, { entries: [], message: 'Log file is empty' });
3958
5287
  return;
3959
5288
  }
3960
- const logFile = (0, path_1.resolve)(logDir, files[0]);
5289
+ json(res, { entries });
5290
+ }
5291
+ async function handlePostFeedback(req, res) {
3961
5292
  try {
3962
- const content = (0, fs_1.readFileSync)(logFile, 'utf-8').trim();
3963
- if (!content) {
3964
- json(res, { entries: [], message: 'Log file is empty' });
5293
+ const rawBody = await readBody(req);
5294
+ let payload = {};
5295
+ if (rawBody.trim().length > 0) {
5296
+ try {
5297
+ payload = JSON.parse(rawBody);
5298
+ }
5299
+ catch {
5300
+ json(res, { ok: false, error: 'Invalid JSON body' }, 400);
5301
+ return;
5302
+ }
5303
+ }
5304
+ const message = typeof payload.message === 'string' ? payload.message.trim() : '';
5305
+ if (!message) {
5306
+ json(res, { ok: false, error: 'Feedback message is required' }, 400);
3965
5307
  return;
3966
5308
  }
3967
- const allLines = content.split('\n');
3968
- const lines = allLines.slice(-50);
3969
- const entries = lines
3970
- .map(line => { try {
3971
- return JSON.parse(line);
5309
+ if (message.length > 8000) {
5310
+ json(res, { ok: false, error: 'Feedback message is too long (max 8000 chars)' }, 400);
5311
+ return;
3972
5312
  }
3973
- catch {
3974
- return null;
3975
- } })
3976
- .filter(Boolean)
3977
- .reverse();
3978
- json(res, { entries });
5313
+ const attachLogs = payload.attach_logs === true || payload.attachLogs === true;
5314
+ const metadata = {
5315
+ attach_logs: attachLogs,
5316
+ };
5317
+ const userAgent = req.headers['user-agent'];
5318
+ if (typeof userAgent === 'string' && userAgent.trim().length > 0) {
5319
+ metadata.user_agent = userAgent.trim().slice(0, 512);
5320
+ }
5321
+ if (attachLogs) {
5322
+ metadata.diagnostics = buildFeedbackDiagnostics();
5323
+ }
5324
+ const result = await (0, feedback_js_1.submitFeedback)(message, { source: 'ui', metadata });
5325
+ json(res, { ok: true, mode: result.mode, detail: result.detail });
3979
5326
  }
3980
5327
  catch (err) {
3981
- json(res, { entries: [], message: 'Could not read log file: ' + (err.message ?? String(err)) });
5328
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
3982
5329
  }
3983
5330
  }
3984
5331
  let containerStatsCache = new Map();
@@ -4083,8 +5430,9 @@ function normalizeMcpEntryForHost(entry, sandboxHome) {
4083
5430
  function collectMcpState() {
4084
5431
  const config = (0, config_js_1.loadConfig)();
4085
5432
  const { sandboxHome, mcpConfigPath, mcpConfigExists, registeredServers } = readMcpConfigData();
4086
- const slurmConfigured = config.slurm.enabled && config.slurm.mcp_server;
4087
- const clusterConfigured = config.slurm.enabled && config.slurm.mcp_server;
5433
+ const slurmPluginEnabled = isPluginEnabledInConfig(config, 'slurm');
5434
+ const slurmConfigured = slurmPluginEnabled && config.slurm.enabled && config.slurm.mcp_server;
5435
+ const clusterConfigured = slurmPluginEnabled && config.slurm.enabled && config.slurm.mcp_server;
4088
5436
  const datasetsConfigured = true;
4089
5437
  const resultsConfigured = true;
4090
5438
  const slurmEntry = registeredServers['labgate-slurm'];
@@ -4186,7 +5534,13 @@ function collectMcpState() {
4186
5534
  tools: [
4187
5535
  { name: 'list_results', title: 'List Results', description: 'List recorded results with filtering and pagination' },
4188
5536
  { name: 'register_result', title: 'Register Result', description: 'Create a new structured result entry' },
5537
+ {
5538
+ name: 'register_reproducible_result',
5539
+ title: 'Register Reproducible Result',
5540
+ description: 'Register a result with script, inputs, requirements, and optional execution/submission',
5541
+ },
4189
5542
  { name: 'get_result', title: 'Get Result', description: 'Retrieve one result by id' },
5543
+ { name: 'list_result_versions', title: 'List Result Versions', description: 'List all versions for a result lineage' },
4190
5544
  { name: 'update_result', title: 'Update Result', description: 'Update an existing result entry' },
4191
5545
  { name: 'delete_result', title: 'Delete Result', description: 'Delete a result entry' },
4192
5546
  ],
@@ -4380,18 +5734,40 @@ function parseResultPayload(raw, mode) {
4380
5734
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
4381
5735
  return { ok: false, error: 'Body must be a JSON object.' };
4382
5736
  }
4383
- if (mode === 'create' && typeof raw.title !== 'string') {
4384
- return { ok: false, error: 'title is required and must be a string.' };
4385
- }
4386
- const optionalStringFields = ['title', 'summary', 'source', 'session_id', 'workdir'];
5737
+ if (mode === 'create' &&
5738
+ typeof raw.title !== 'string' &&
5739
+ typeof raw.version_of !== 'string') {
5740
+ return { ok: false, error: 'title is required and must be a string (or provide version_of).' };
5741
+ }
5742
+ if (mode === 'update' && raw.version_of !== undefined) {
5743
+ return { ok: false, error: 'version_of is only supported when creating a new result.' };
5744
+ }
5745
+ const optionalStringFields = [
5746
+ 'id',
5747
+ 'title',
5748
+ 'summary',
5749
+ 'source',
5750
+ 'session_id',
5751
+ 'workdir',
5752
+ 'lineage_id',
5753
+ 'previous_result_id',
5754
+ 'version_of',
5755
+ ];
4387
5756
  for (const field of optionalStringFields) {
4388
5757
  if (raw[field] !== undefined && raw[field] !== null && typeof raw[field] !== 'string') {
4389
5758
  return { ok: false, error: `${field} must be a string or null.` };
4390
5759
  }
4391
5760
  }
5761
+ if (raw.starred !== undefined && typeof raw.starred !== 'boolean') {
5762
+ return { ok: false, error: 'starred must be a boolean.' };
5763
+ }
4392
5764
  if (raw.details !== undefined && raw.details !== null && typeof raw.details !== 'string') {
4393
5765
  return { ok: false, error: 'details must be a string or null.' };
4394
5766
  }
5767
+ if (raw.version !== undefined &&
5768
+ (!Number.isInteger(raw.version) || raw.version < 1)) {
5769
+ return { ok: false, error: 'version must be an integer >= 1.' };
5770
+ }
4395
5771
  if (raw.tags !== undefined &&
4396
5772
  (!Array.isArray(raw.tags) || raw.tags.some((v) => typeof v !== 'string'))) {
4397
5773
  return { ok: false, error: 'tags must be an array of strings.' };
@@ -4405,33 +5781,111 @@ function parseResultPayload(raw, mode) {
4405
5781
  (typeof raw.metadata !== 'object' || Array.isArray(raw.metadata))) {
4406
5782
  return { ok: false, error: 'metadata must be an object or null.' };
4407
5783
  }
5784
+ if (raw.reproducibility !== undefined &&
5785
+ raw.reproducibility !== null &&
5786
+ (typeof raw.reproducibility !== 'object' || Array.isArray(raw.reproducibility))) {
5787
+ return { ok: false, error: 'reproducibility must be an object or null.' };
5788
+ }
5789
+ if (raw.reproducibility && typeof raw.reproducibility === 'object') {
5790
+ const repro = raw.reproducibility;
5791
+ const reproStringFields = [
5792
+ 'script_path',
5793
+ 'script_kind',
5794
+ 'script_sha256',
5795
+ 'runtime',
5796
+ 'strategy',
5797
+ 'precomputed_reason',
5798
+ ];
5799
+ for (const field of reproStringFields) {
5800
+ if (repro[field] !== undefined && repro[field] !== null && typeof repro[field] !== 'string') {
5801
+ return { ok: false, error: `reproducibility.${field} must be a string or null.` };
5802
+ }
5803
+ }
5804
+ if (repro.input_files !== undefined &&
5805
+ (!Array.isArray(repro.input_files) || repro.input_files.some((v) => typeof v !== 'string'))) {
5806
+ return { ok: false, error: 'reproducibility.input_files must be an array of strings.' };
5807
+ }
5808
+ if (repro.requirements !== undefined &&
5809
+ (!Array.isArray(repro.requirements) || repro.requirements.some((v) => typeof v !== 'string'))) {
5810
+ return { ok: false, error: 'reproducibility.requirements must be an array of strings.' };
5811
+ }
5812
+ if (repro.execution !== undefined &&
5813
+ repro.execution !== null &&
5814
+ (typeof repro.execution !== 'object' || Array.isArray(repro.execution))) {
5815
+ return { ok: false, error: 'reproducibility.execution must be an object or null.' };
5816
+ }
5817
+ if (repro.execution && typeof repro.execution === 'object') {
5818
+ const execution = repro.execution;
5819
+ const execStringFields = ['status', 'command', 'ran_at', 'signal', 'stdout_tail', 'stderr_tail', 'slurm_job_id'];
5820
+ for (const field of execStringFields) {
5821
+ if (execution[field] !== undefined && execution[field] !== null && typeof execution[field] !== 'string') {
5822
+ return { ok: false, error: `reproducibility.execution.${field} must be a string or null.` };
5823
+ }
5824
+ }
5825
+ if (execution.duration_ms !== undefined &&
5826
+ execution.duration_ms !== null &&
5827
+ (!Number.isInteger(execution.duration_ms) || execution.duration_ms < 0)) {
5828
+ return { ok: false, error: 'reproducibility.execution.duration_ms must be an integer >= 0.' };
5829
+ }
5830
+ if (execution.exit_code !== undefined &&
5831
+ execution.exit_code !== null &&
5832
+ !Number.isInteger(execution.exit_code)) {
5833
+ return { ok: false, error: 'reproducibility.execution.exit_code must be an integer or null.' };
5834
+ }
5835
+ }
5836
+ }
4408
5837
  const payload = {
5838
+ id: raw.id,
4409
5839
  title: raw.title,
4410
5840
  summary: raw.summary,
4411
5841
  details: raw.details,
4412
5842
  source: raw.source,
5843
+ starred: raw.starred,
4413
5844
  session_id: raw.session_id,
4414
5845
  workdir: raw.workdir,
5846
+ lineage_id: raw.lineage_id,
5847
+ version: raw.version,
5848
+ previous_result_id: raw.previous_result_id,
5849
+ version_of: raw.version_of,
4415
5850
  tags: raw.tags,
4416
5851
  artifacts: raw.artifacts,
4417
5852
  metadata: raw.metadata,
5853
+ reproducibility: raw.reproducibility,
4418
5854
  };
4419
5855
  if (mode === 'update') {
4420
5856
  const hasPatch = payload.title !== undefined ||
4421
5857
  payload.summary !== undefined ||
4422
5858
  payload.details !== undefined ||
4423
5859
  payload.source !== undefined ||
5860
+ payload.starred !== undefined ||
4424
5861
  payload.session_id !== undefined ||
4425
5862
  payload.workdir !== undefined ||
5863
+ payload.lineage_id !== undefined ||
5864
+ payload.version !== undefined ||
5865
+ payload.previous_result_id !== undefined ||
5866
+ payload.version_of !== undefined ||
4426
5867
  payload.tags !== undefined ||
4427
5868
  payload.artifacts !== undefined ||
4428
- payload.metadata !== undefined;
5869
+ payload.metadata !== undefined ||
5870
+ payload.reproducibility !== undefined;
4429
5871
  if (!hasPatch) {
4430
5872
  return { ok: false, error: 'No update fields provided.' };
4431
5873
  }
4432
5874
  }
4433
5875
  return { ok: true, payload };
4434
5876
  }
5877
+ function parseOptionalBooleanQuery(value) {
5878
+ if (value === null)
5879
+ return undefined;
5880
+ const normalized = String(value).trim().toLowerCase();
5881
+ if (!normalized)
5882
+ return undefined;
5883
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on')
5884
+ return true;
5885
+ if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off')
5886
+ return false;
5887
+ return undefined;
5888
+ }
4435
5889
  function handleGetResults(reqUrl, res) {
4436
5890
  const parsedLimit = parseInt(reqUrl.searchParams.get('limit') || '100', 10);
4437
5891
  const parsedOffset = parseInt(reqUrl.searchParams.get('offset') || '0', 10);
@@ -4442,6 +5896,8 @@ function handleGetResults(reqUrl, res) {
4442
5896
  search: reqUrl.searchParams.get('search') || undefined,
4443
5897
  source: reqUrl.searchParams.get('source') || undefined,
4444
5898
  tag: reqUrl.searchParams.get('tag') || undefined,
5899
+ lineage: reqUrl.searchParams.get('lineage') || undefined,
5900
+ starred: parseOptionalBooleanQuery(reqUrl.searchParams.get('starred')),
4445
5901
  limit,
4446
5902
  offset,
4447
5903
  });
@@ -4988,6 +6444,7 @@ function startSSEBroadcast() {
4988
6444
  catch { /* skip */ }
4989
6445
  }
4990
6446
  }
6447
+ await annotateSessionsWithGitState(sessions);
4991
6448
  // Collect container resource stats
4992
6449
  const sessionIds = sessions.map((s) => s.id).filter(Boolean);
4993
6450
  try {
@@ -6004,6 +7461,10 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
6004
7461
  if (!current || !current.pty)
6005
7462
  return;
6006
7463
  if (parsed?.type === 'input' && typeof parsed.data === 'string') {
7464
+ // Cancel pending automation when human types
7465
+ const autoEngine = automationEngines.get(id);
7466
+ if (autoEngine)
7467
+ autoEngine.cancelPending();
6007
7468
  try {
6008
7469
  current.pty.write(parsed.data);
6009
7470
  }
@@ -6157,6 +7618,20 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
6157
7618
  !requireWriteToken(req, res)) {
6158
7619
  return;
6159
7620
  }
7621
+ if (pathname.startsWith('/api/explorer/')) {
7622
+ const config = (0, config_js_1.loadConfig)();
7623
+ if (!isPluginEnabledInConfig(config, 'explorer')) {
7624
+ json(res, { ok: false, error: 'Explorer plugin is disabled.' }, 403);
7625
+ return;
7626
+ }
7627
+ }
7628
+ if (pathname.startsWith('/api/automation/')) {
7629
+ const config = (0, config_js_1.loadConfig)();
7630
+ if (!isPluginEnabledInConfig(config, 'automation')) {
7631
+ json(res, { ok: false, error: 'Automation plugin is disabled.' }, 403);
7632
+ return;
7633
+ }
7634
+ }
6160
7635
  if (pathname === '/' && method === 'GET') {
6161
7636
  serveHTML(res);
6162
7637
  }
@@ -6169,6 +7644,43 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
6169
7644
  else if (pathname === '/api/config/path' && method === 'GET') {
6170
7645
  handleGetConfigPath(req, res);
6171
7646
  }
7647
+ else if (pathname === '/api/plugins' && method === 'GET') {
7648
+ handleGetPlugins(req, res);
7649
+ }
7650
+ else if (pathname === '/api/plugins' && method === 'POST') {
7651
+ await handlePostPlugins(req, res);
7652
+ }
7653
+ else if (/^\/api\/automation\/status\/[^/]+$/.test(pathname) && method === 'GET') {
7654
+ const terminalId = pathname.split('/').pop();
7655
+ handleGetAutomationStatus(terminalId, res);
7656
+ }
7657
+ else if (pathname === '/api/automation/enable' && method === 'POST') {
7658
+ await handlePostAutomationEnable(req, res);
7659
+ }
7660
+ else if (pathname === '/api/automation/disable' && method === 'POST') {
7661
+ await handlePostAutomationDisable(req, res);
7662
+ }
7663
+ else if (pathname === '/api/automation/pause' && method === 'POST') {
7664
+ await handlePostAutomationPause(req, res);
7665
+ }
7666
+ else if (pathname === '/api/automation/resume' && method === 'POST') {
7667
+ await handlePostAutomationResume(req, res);
7668
+ }
7669
+ else if (pathname === '/api/automation/config' && method === 'POST') {
7670
+ await handlePostAutomationConfig(req, res);
7671
+ }
7672
+ else if (/^\/api\/automation\/log\/[^/]+$/.test(pathname) && method === 'GET') {
7673
+ const terminalId = pathname.split('/').pop();
7674
+ handleGetAutomationLog(terminalId, res);
7675
+ }
7676
+ else if (/^\/api\/automation\/turns\/[^/]+$/.test(pathname) && method === 'GET') {
7677
+ const terminalId = pathname.split('/').pop();
7678
+ handleGetAutomationTurns(decodeURIComponent(terminalId), res);
7679
+ }
7680
+ else if (/^\/api\/automation\/terminal-status\/[^/]+$/.test(pathname) && method === 'GET') {
7681
+ const terminalId = pathname.split('/').pop();
7682
+ handleGetAutomationTerminalStatus(terminalId, res);
7683
+ }
6172
7684
  else if (pathname === '/api/ui/version' && method === 'GET') {
6173
7685
  await handleGetUiVersion(reqUrl, res);
6174
7686
  }
@@ -6187,12 +7699,27 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
6187
7699
  else if (pathname === '/api/sessions/restart' && method === 'POST') {
6188
7700
  await handleRestartSession(req, res);
6189
7701
  }
7702
+ else if (/^\/api\/sessions\/[^/]+\/git\/branches$/.test(pathname) && method === 'GET') {
7703
+ await handleGetSessionGitBranches(pathname, res);
7704
+ }
7705
+ else if (/^\/api\/sessions\/[^/]+\/git\/dag$/.test(pathname) && method === 'GET') {
7706
+ await handleGetSessionGitDag(pathname, reqUrl, res);
7707
+ }
7708
+ else if (/^\/api\/sessions\/[^/]+\/git\/checkout$/.test(pathname) && method === 'POST') {
7709
+ await handlePostSessionGitCheckout(pathname, req, res);
7710
+ }
7711
+ else if (/^\/api\/sessions\/[^/]+\/git\/branches$/.test(pathname) && method === 'POST') {
7712
+ await handlePostSessionGitBranchCreate(pathname, req, res);
7713
+ }
6190
7714
  else if (pathname === '/api/validate-path' && method === 'POST') {
6191
7715
  await handleValidatePath(req, res);
6192
7716
  }
6193
7717
  else if (pathname === '/api/browse-dir' && method === 'POST') {
6194
7718
  await handleBrowseDir(req, res);
6195
7719
  }
7720
+ else if (pathname === '/api/files/read' && method === 'GET') {
7721
+ handleReadWorkspaceFile(reqUrl, res);
7722
+ }
6196
7723
  else if (pathname === '/api/dataset-stats' && method === 'GET') {
6197
7724
  handleGetDatasetStats(req, res);
6198
7725
  }
@@ -6211,6 +7738,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
6211
7738
  else if (pathname === '/api/logs' && method === 'GET') {
6212
7739
  handleGetLogs(req, res);
6213
7740
  }
7741
+ else if (pathname === '/api/feedback' && method === 'POST') {
7742
+ await handlePostFeedback(req, res);
7743
+ }
6214
7744
  else if (pathname === '/api/security' && method === 'GET') {
6215
7745
  handleGetSecurity(req, res);
6216
7746
  }