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