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