labgate 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +48 -3
  2. package/dist/cli.js +322 -19
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/audit.d.ts +5 -1
  5. package/dist/lib/audit.js +19 -3
  6. package/dist/lib/audit.js.map +1 -1
  7. package/dist/lib/config.d.ts +71 -2
  8. package/dist/lib/config.js +192 -8
  9. package/dist/lib/config.js.map +1 -1
  10. package/dist/lib/container.d.ts +54 -0
  11. package/dist/lib/container.js +650 -178
  12. package/dist/lib/container.js.map +1 -1
  13. package/dist/lib/init.js +22 -9
  14. package/dist/lib/init.js.map +1 -1
  15. package/dist/lib/license.d.ts +44 -0
  16. package/dist/lib/license.js +164 -0
  17. package/dist/lib/license.js.map +1 -0
  18. package/dist/lib/policy.d.ts +85 -0
  19. package/dist/lib/policy.js +321 -0
  20. package/dist/lib/policy.js.map +1 -0
  21. package/dist/lib/runtime.d.ts +2 -2
  22. package/dist/lib/runtime.js +19 -36
  23. package/dist/lib/runtime.js.map +1 -1
  24. package/dist/lib/slurm-db.d.ts +51 -0
  25. package/dist/lib/slurm-db.js +179 -0
  26. package/dist/lib/slurm-db.js.map +1 -0
  27. package/dist/lib/slurm-mcp.d.ts +12 -0
  28. package/dist/lib/slurm-mcp.js +347 -0
  29. package/dist/lib/slurm-mcp.js.map +1 -0
  30. package/dist/lib/slurm-poller.d.ts +36 -0
  31. package/dist/lib/slurm-poller.js +423 -0
  32. package/dist/lib/slurm-poller.js.map +1 -0
  33. package/dist/lib/test/integration-harness.d.ts +44 -0
  34. package/dist/lib/test/integration-harness.js +260 -0
  35. package/dist/lib/test/integration-harness.js.map +1 -0
  36. package/dist/lib/ui.d.ts +34 -1
  37. package/dist/lib/ui.html +3081 -356
  38. package/dist/lib/ui.js +2123 -108
  39. package/dist/lib/ui.js.map +1 -1
  40. package/package.json +11 -3
@@ -36,11 +36,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.computeMountFingerprint = computeMountFingerprint;
39
40
  exports.imageToSifName = imageToSifName;
40
41
  exports.buildEntrypoint = buildEntrypoint;
42
+ exports.setupBrowserHook = setupBrowserHook;
41
43
  exports.startSession = startSession;
42
44
  exports.listSessions = listSessions;
43
45
  exports.stopSession = stopSession;
46
+ exports.restartSession = restartSession;
44
47
  const child_process_1 = require("child_process");
45
48
  const fs_1 = require("fs");
46
49
  const path_1 = require("path");
@@ -50,8 +53,245 @@ const fast_glob_1 = __importDefault(require("fast-glob"));
50
53
  const config_js_1 = require("./config.js");
51
54
  const runtime_js_1 = require("./runtime.js");
52
55
  const audit_js_1 = require("./audit.js");
56
+ const slurm_db_js_1 = require("./slurm-db.js");
57
+ const slurm_poller_js_1 = require("./slurm-poller.js");
53
58
  const log = __importStar(require("./log.js"));
54
- const ui_js_1 = require("./ui.js");
59
+ /**
60
+ * Compute a deterministic fingerprint of mount-affecting config fields.
61
+ * Used to detect when running sessions need a restart after config changes.
62
+ * Only includes fields that are baked into container args at launch time
63
+ * and cannot be hot-reloaded (--bind, --volume, --network, image, runtime).
64
+ */
65
+ function computeMountFingerprint(config, imageOverride) {
66
+ const input = JSON.stringify({
67
+ datasets: (config.datasets || []).map(d => ({ path: d.path, name: d.name, mode: d.mode })),
68
+ extra_paths: (config.filesystem.extra_paths || []).map(p => ({ path: p.path, mode: p.mode })),
69
+ image: imageOverride ?? config.image,
70
+ network_mode: config.network.mode,
71
+ runtime: config.runtime,
72
+ });
73
+ return (0, crypto_1.createHash)('sha256').update(input).digest('hex').slice(0, 16);
74
+ }
75
+ function writeSessionFile(sessionId, session, image) {
76
+ const dir = (0, config_js_1.getSessionsDir)();
77
+ (0, config_js_1.ensurePrivateDir)(dir);
78
+ const data = {
79
+ id: sessionId,
80
+ agent: session.agent,
81
+ workdir: session.workdir,
82
+ node: (0, os_1.hostname)(),
83
+ pid: process.pid,
84
+ started: new Date().toISOString(),
85
+ network: session.config.network.mode,
86
+ image,
87
+ user: (0, os_1.userInfo)().username,
88
+ configFingerprint: computeMountFingerprint(session.config, session.imageOverride),
89
+ };
90
+ const content = JSON.stringify(data, null, 2) + '\n';
91
+ const sessionFile = (0, path_1.join)(dir, `${sessionId}.json`);
92
+ (0, fs_1.writeFileSync)(sessionFile, content, { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
93
+ (0, config_js_1.ensurePrivateFile)(sessionFile);
94
+ // Enterprise: also write to shared sessions dir for admin visibility
95
+ if (session.sharedSessionsDir) {
96
+ try {
97
+ const sharedFile = (0, path_1.join)(session.sharedSessionsDir, `${sessionId}.json`);
98
+ (0, fs_1.writeFileSync)(sharedFile, content, { encoding: 'utf-8', mode: 0o664 });
99
+ }
100
+ catch {
101
+ // Best effort; shared dir may not be writable
102
+ }
103
+ }
104
+ }
105
+ function removeSessionFile(sessionId, sharedSessionsDir) {
106
+ try {
107
+ (0, fs_1.unlinkSync)((0, path_1.join)((0, config_js_1.getSessionsDir)(), `${sessionId}.json`));
108
+ }
109
+ catch { /* already removed */ }
110
+ if (sharedSessionsDir) {
111
+ try {
112
+ (0, fs_1.unlinkSync)((0, path_1.join)(sharedSessionsDir, `${sessionId}.json`));
113
+ }
114
+ catch { /* best effort */ }
115
+ }
116
+ }
117
+ const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
118
+ const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
119
+ function getInstructionFileForAgent(agent) {
120
+ return agent.toLowerCase() === 'claude' ? 'CLAUDE.md' : 'AGENTS.md';
121
+ }
122
+ function escapeRegExp(value) {
123
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
124
+ }
125
+ function stripLabgateInstructionBlock(content) {
126
+ const pattern = new RegExp(`${escapeRegExp(LABGATE_INSTRUCTION_START)}[\\s\\S]*?${escapeRegExp(LABGATE_INSTRUCTION_END)}\\n?`, 'g');
127
+ return content.replace(pattern, '').replace(/^\n+/, '');
128
+ }
129
+ function hasOtherActiveSessionForInstructionFile(workdir, filename) {
130
+ const dir = (0, config_js_1.getSessionsDir)();
131
+ if (!(0, fs_1.existsSync)(dir))
132
+ return false;
133
+ const localHost = (0, os_1.hostname)();
134
+ try {
135
+ const files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.json'));
136
+ for (const file of files) {
137
+ try {
138
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
139
+ if (data.workdir !== workdir)
140
+ continue;
141
+ if (getInstructionFileForAgent(data.agent) !== filename)
142
+ continue;
143
+ if (data.node === localHost) {
144
+ try {
145
+ process.kill(data.pid, 0);
146
+ }
147
+ catch {
148
+ try {
149
+ (0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
150
+ }
151
+ catch { /* best effort */ }
152
+ continue;
153
+ }
154
+ }
155
+ return true;
156
+ }
157
+ catch { /* skip bad session files */ }
158
+ }
159
+ }
160
+ catch { /* skip unreadable dir */ }
161
+ return false;
162
+ }
163
+ function cleanupLabgateInstructionFile(workdir, filename, deleteWhenEmpty) {
164
+ if (hasOtherActiveSessionForInstructionFile(workdir, filename))
165
+ return;
166
+ const targetPath = (0, path_1.join)(workdir, filename);
167
+ try {
168
+ if (!(0, fs_1.existsSync)(targetPath))
169
+ return;
170
+ const current = (0, fs_1.readFileSync)(targetPath, 'utf-8');
171
+ const stripped = stripLabgateInstructionBlock(current);
172
+ if (stripped.trim().length === 0) {
173
+ if (deleteWhenEmpty) {
174
+ try {
175
+ (0, fs_1.unlinkSync)(targetPath);
176
+ }
177
+ catch { /* best effort */ }
178
+ }
179
+ else if (stripped !== current) {
180
+ (0, fs_1.writeFileSync)(targetPath, '', 'utf-8');
181
+ }
182
+ }
183
+ else if (stripped !== current) {
184
+ (0, fs_1.writeFileSync)(targetPath, stripped, 'utf-8');
185
+ }
186
+ }
187
+ catch (err) {
188
+ log.warn(`Could not clean up ${filename}: ${err.message ?? String(err)}`);
189
+ }
190
+ }
191
+ function buildLabgateInstructionBlock(session) {
192
+ const lines = [
193
+ LABGATE_INSTRUCTION_START,
194
+ '## LabGate Sandbox Context (Auto-Managed)',
195
+ '- You are running inside a LabGate sandbox container.',
196
+ '- Path mapping:',
197
+ ` - Container \`/work\` maps to host \`${session.workdir}\``,
198
+ ` - Container \`/home/sandbox\` maps to host \`${(0, config_js_1.getSandboxHome)()}\``,
199
+ ];
200
+ for (const mount of session.config.filesystem.extra_paths) {
201
+ const hostPath = mount.path.replace(/^~/, (0, os_1.homedir)());
202
+ lines.push(` - Container \`/mnt/${(0, path_1.basename)(hostPath)}\` maps to host \`${hostPath}\` (${mount.mode})`);
203
+ }
204
+ const datasets = session.config.datasets || [];
205
+ for (const ds of datasets) {
206
+ const hostPath = ds.path.replace(/^~/, (0, os_1.homedir)());
207
+ lines.push(` - Container \`/datasets/${ds.name}\` maps to host \`${hostPath}\` (${ds.mode})`);
208
+ }
209
+ lines.push('- Treat other host paths as unavailable unless explicitly mounted.');
210
+ lines.push('- When reporting file paths to the user, prefer showing both container and host paths when helpful.');
211
+ if (datasets.length > 0) {
212
+ lines.push('');
213
+ lines.push('### Available Datasets');
214
+ lines.push('The following named datasets are mounted and available for analysis:');
215
+ for (const ds of datasets) {
216
+ const desc = ds.description ? ` — ${ds.description}` : '';
217
+ lines.push(`- **${ds.name}** at \`/datasets/${ds.name}\` (${ds.mode})${desc}`);
218
+ }
219
+ lines.push('');
220
+ lines.push('Use these dataset paths directly when the user references data by name.');
221
+ }
222
+ if (session.config.slurm.enabled) {
223
+ lines.push('');
224
+ lines.push('### SLURM Integration');
225
+ lines.push('- SLURM commands (srun, sbatch, squeue, scancel) are available.');
226
+ lines.push('- LabGate tracks your SLURM jobs automatically via squeue polling.');
227
+ if (session.config.slurm.mcp_server && session.agent.toLowerCase() === 'claude') {
228
+ lines.push('- Use the `labgate-slurm` MCP tools to check job status and read output files.');
229
+ lines.push('- Available tools: `list_slurm_jobs`, `get_slurm_job`, `get_slurm_output`, `cancel_slurm_job`, `set_slurm_job_notes`');
230
+ lines.push('- Jobs may have user-annotated notes visible via `get_slurm_job`. Use `set_slurm_job_notes` to add your own observations.');
231
+ }
232
+ lines.push('- When submitting jobs, prefer using `--output` and `--error` flags to set explicit paths.');
233
+ }
234
+ lines.push(LABGATE_INSTRUCTION_END);
235
+ return lines.join('\n');
236
+ }
237
+ /**
238
+ * Inject a LabGate-managed instruction block into the agent-specific instruction file.
239
+ * The block is removed when the session ends unless another active session
240
+ * still manages the same instruction file in that workdir.
241
+ */
242
+ function installLabgateInstruction(session) {
243
+ const filename = getInstructionFileForAgent(session.agent);
244
+ const targetPath = (0, path_1.join)(session.workdir, filename);
245
+ const existedBefore = (0, fs_1.existsSync)(targetPath);
246
+ try {
247
+ const current = existedBefore ? (0, fs_1.readFileSync)(targetPath, 'utf-8') : '';
248
+ const stripped = stripLabgateInstructionBlock(current);
249
+ const block = buildLabgateInstructionBlock(session);
250
+ const next = stripped.trim().length === 0
251
+ ? `${block}\n`
252
+ : `${block}\n\n${stripped}`;
253
+ if (next !== current) {
254
+ (0, fs_1.writeFileSync)(targetPath, next, 'utf-8');
255
+ }
256
+ }
257
+ catch (err) {
258
+ log.warn(`Could not write ${filename}: ${err.message ?? String(err)}`);
259
+ return () => { };
260
+ }
261
+ return () => cleanupLabgateInstructionFile(session.workdir, filename, !existedBefore);
262
+ }
263
+ /**
264
+ * Clean up stale session files from dead local processes.
265
+ * Called on session start to tidy up after crashes.
266
+ */
267
+ function cleanStaleSessionFiles() {
268
+ const dir = (0, config_js_1.getSessionsDir)();
269
+ if (!(0, fs_1.existsSync)(dir))
270
+ return;
271
+ const localHost = (0, os_1.hostname)();
272
+ try {
273
+ const files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.json'));
274
+ for (const file of files) {
275
+ try {
276
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
277
+ // Only clean up sessions from this node
278
+ if (data.node !== localHost)
279
+ continue;
280
+ // Check if the process is still alive
281
+ try {
282
+ process.kill(data.pid, 0);
283
+ }
284
+ catch {
285
+ // Process is dead — remove stale file
286
+ (0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
287
+ cleanupLabgateInstructionFile(data.workdir, getInstructionFileForAgent(data.agent), false);
288
+ }
289
+ }
290
+ catch { /* skip unparseable files */ }
291
+ }
292
+ }
293
+ catch { /* dir read error */ }
294
+ }
55
295
  // ── SIF image management (Apptainer/Singularity) ─────────
56
296
  /**
57
297
  * Convert a container image URI to a local SIF filename.
@@ -85,7 +325,7 @@ function ensureSifImage(runtime, image) {
85
325
  }
86
326
  return sifPath;
87
327
  }
88
- // ── OCI image management (Podman/Docker) ─────────────────
328
+ // ── OCI image management (Docker) ────────────────────────
89
329
  function ensureOciImage(runtime, image) {
90
330
  try {
91
331
  if (runtime === 'docker') {
@@ -107,7 +347,6 @@ function ensureOciImage(runtime, image) {
107
347
  if (runtime === 'docker') {
108
348
  log.step('Fix: sudo usermod -aG docker $USER && newgrp docker');
109
349
  }
110
- log.step('Or use podman (rootless): labgate config set runtime podman');
111
350
  }
112
351
  else {
113
352
  log.error(`Failed to pull image "${image}" with ${runtime}.`);
@@ -173,6 +412,11 @@ function getMountRoots(session) {
173
412
  const target = `/mnt/${(0, path_1.basename)(resolved)}`;
174
413
  return { host: resolved, container: target };
175
414
  }),
415
+ // Datasets mounted under /datasets/{name}
416
+ ...(config.datasets || []).map(({ path: p, name }) => {
417
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
418
+ return { host: resolved, container: `/datasets/${name}` };
419
+ }),
176
420
  ];
177
421
  }
178
422
  // ── Dry-run runtime fallback ──────────────────────────────
@@ -223,13 +467,13 @@ function buildFilteredProxyEnv(config) {
223
467
  }
224
468
  return env;
225
469
  }
226
- function buildNetworkArgs(config, runtime) {
470
+ function buildNetworkArgs(config) {
227
471
  if (config.network.mode === 'none')
228
472
  return ['--network=none'];
229
473
  if (config.network.mode === 'host')
230
474
  return ['--network=host'];
231
475
  // filtered
232
- return runtime === 'podman' ? ['--network=slirp4netns'] : ['--network=bridge'];
476
+ return ['--network=bridge'];
233
477
  }
234
478
  function prepareCommonArgs(session, sessionId, tokenEnv) {
235
479
  const { agent, config } = session;
@@ -250,8 +494,8 @@ function prepareCommonArgs(session, sessionId, tokenEnv) {
250
494
  ];
251
495
  return { blockedMounts, emptyDir, envArgs };
252
496
  }
253
- // ── Build Podman/Docker arguments ─────────────────────────
254
- function buildPodmanArgs(session, runtime, sessionId, tokenEnv = []) {
497
+ // ── Build Docker arguments ────────────────────────────────
498
+ function buildDockerArgs(session, sessionId, tokenEnv = []) {
255
499
  const { agent, workdir, config, imageOverride } = session;
256
500
  const image = imageOverride ?? config.image;
257
501
  const sandboxHome = (0, config_js_1.getSandboxHome)();
@@ -267,7 +511,7 @@ function buildPodmanArgs(session, runtime, sessionId, tokenEnv = []) {
267
511
  '--security-opt=no-new-privileges',
268
512
  '--pids-limit=512',
269
513
  // ── Network ──
270
- ...buildNetworkArgs(config, runtime),
514
+ ...buildNetworkArgs(config),
271
515
  // ── Persistent sandbox HOME ──
272
516
  '--volume', `${sandboxHome}:/home/sandbox:rw`,
273
517
  // ── Working directory ──
@@ -279,6 +523,11 @@ function buildPodmanArgs(session, runtime, sessionId, tokenEnv = []) {
279
523
  const target = `/mnt/${(0, path_1.basename)(resolved)}`;
280
524
  return `--volume=${resolved}:${target}:${mode}`;
281
525
  }),
526
+ // ── Dataset mounts ──
527
+ ...(config.datasets || []).map(({ path: p, name, mode }) => {
528
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
529
+ return `--volume=${resolved}:/datasets/${name}:${mode}`;
530
+ }),
282
531
  // ── Block sensitive paths ──
283
532
  ...blockedMounts.flatMap(({ containerPath, kind }) => {
284
533
  const source = kind === 'dir' ? emptyDir : '/dev/null';
@@ -313,6 +562,12 @@ function buildApptainerArgs(session, runtime, sifPath, sessionId, tokenEnv = [])
313
562
  const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
314
563
  return ['--bind', bindSpec];
315
564
  }),
565
+ // ── Dataset mounts ──
566
+ ...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
567
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
568
+ const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
569
+ return ['--bind', bindSpec];
570
+ }),
316
571
  // ── Block sensitive paths ──
317
572
  ...blockedMounts.flatMap(({ containerPath, kind }) => {
318
573
  const source = kind === 'dir' ? emptyDir : '/dev/null';
@@ -367,38 +622,101 @@ function buildEntrypoint(agent) {
367
622
  lines.push(`if ! command -v ${setup.bin} >/dev/null 2>&1; then`, ` echo "[labgate] Installing ${setup.pkg}..."`, ` ${setup.installer}`, 'fi', `echo "[labgate] Starting ${setup.bin} in /work"`, `exec ${setup.bin}`);
368
623
  return lines.join('\n');
369
624
  }
370
- // ── OAuth URL interceptor (watches container stdout) ──────
625
+ // ── Browser-open hook for OAuth (via sandbox home) ────────
626
+ /**
627
+ * Detect whether we can open a browser on this machine.
628
+ * Returns false for SSH sessions, headless servers, etc.
629
+ */
630
+ function canOpenBrowser() {
631
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
632
+ return false;
633
+ if ((0, os_1.platform)() === 'darwin')
634
+ return true;
635
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY)
636
+ return true;
637
+ return false;
638
+ }
639
+ /**
640
+ * Copy text to the user's local clipboard via OSC 52 escape sequence.
641
+ * Works over SSH in terminals that support it: iTerm2, kitty, alacritty,
642
+ * Windows Terminal, tmux (with set-clipboard on), foot, WezTerm, etc.
643
+ */
644
+ function osc52Copy(text) {
645
+ const encoded = Buffer.from(text).toString('base64');
646
+ // Write directly to the TTY to bypass any stdout buffering/piping
647
+ const tty = process.stderr.isTTY ? process.stderr : process.stdout;
648
+ tty.write(`\x1b]52;c;${encoded}\x07`);
649
+ }
650
+ const OAUTH_URL_DEDUPE_WINDOW_MS = 20_000;
651
+ let lastHandledOAuthUrl = '';
652
+ let lastHandledOAuthAt = 0;
653
+ function handleOAuthUrl(url, options) {
654
+ const now = Date.now();
655
+ if (url === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
656
+ return;
657
+ }
658
+ lastHandledOAuthUrl = url;
659
+ lastHandledOAuthAt = now;
660
+ if (options.isRemote) {
661
+ // SSH / headless: push URL to local clipboard via OSC 52 if supported.
662
+ osc52Copy(url);
663
+ log.info(`Login URL:\n${url}`);
664
+ log.info('Tried to copy URL via terminal clipboard (OSC 52).');
665
+ log.info('Open it in your local browser, then paste the code back here.');
666
+ return;
667
+ }
668
+ if (options.hostPlatform === 'darwin') {
669
+ try {
670
+ options.execSync('pbcopy', [], { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
671
+ }
672
+ catch { /* best effort */ }
673
+ try {
674
+ options.execSync('open', [url], { stdio: 'ignore' });
675
+ log.success('Login URL opened in browser');
676
+ }
677
+ catch {
678
+ log.warn('Could not auto-open browser. URL copied to clipboard (if available).');
679
+ log.info(`Login URL:\n${url}`);
680
+ }
681
+ return;
682
+ }
683
+ // Local Linux with display
684
+ try {
685
+ options.execSync('xdg-open', [url], { stdio: 'ignore' });
686
+ log.success('Login URL opened in browser');
687
+ }
688
+ catch {
689
+ osc52Copy(url);
690
+ log.warn('Could not auto-open browser. Tried clipboard copy via terminal.');
691
+ log.info(`Login URL:\n${url}`);
692
+ }
693
+ }
371
694
  /**
372
695
  * Creates a function that buffers container output and watches for OAuth
373
- * URLs. When found (even if line-wrapped), reassembles the full URL and
374
- * copies it to the local clipboard via OSC 52.
375
- *
376
- * Works regardless of whether the BROWSER hook fires — this is a safety
377
- * net that catches the URL directly from Claude Code's terminal output.
696
+ * URLs. Works as a fallback if the BROWSER hook isn't triggered.
378
697
  */
379
- function createOAuthInterceptor() {
698
+ function createOAuthInterceptor(options = {}) {
380
699
  let buffer = '';
381
700
  let handled = false;
701
+ const isRemote = options.forceRemote ?? !canOpenBrowser();
702
+ const hostPlatform = options.platformOverride ?? (0, os_1.platform)();
703
+ const execSync = options.execSync ?? child_process_1.execFileSync;
382
704
  return {
383
705
  feed(data) {
384
706
  if (handled)
385
707
  return data;
386
708
  buffer += data;
387
- // Keep buffer from growing unbounded — only need to look at recent output
709
+ // Keep buffer from growing unbounded — only need to look at recent output.
388
710
  if (buffer.length > 8000) {
389
711
  buffer = buffer.slice(-6000);
390
712
  }
391
- // Look for the OAuth URL pattern — it may be split across lines
392
- // Claude Code wraps long URLs at terminal width boundaries
713
+ // Look for the OAuth URL pattern — it may be split across lines.
393
714
  const match = buffer.match(/https:\/\/claude\.ai\/oauth\/authorize\?[^\s]*/);
394
715
  if (!match)
395
716
  return data;
396
- // Found a URL start. Now collect the full URL by stripping line breaks
397
- // that Claude Code's terminal wrapping inserted.
717
+ // Reassemble wrapped URL by stripping terminal-inserted whitespace.
398
718
  const urlStart = buffer.indexOf(match[0]);
399
719
  let raw = buffer.slice(urlStart);
400
- // The URL ends at "Paste code" or double newline or a line that
401
- // doesn't look like URL continuation
402
720
  const endPatterns = [/\n\s*\n/, /Paste code/, /\n\s*$/];
403
721
  for (const pat of endPatterns) {
404
722
  const endMatch = raw.match(pat);
@@ -406,63 +724,39 @@ function createOAuthInterceptor() {
406
724
  raw = raw.slice(0, endMatch.index);
407
725
  }
408
726
  }
409
- // Strip whitespace/newlines that the terminal wrapping inserted
410
727
  const cleanUrl = raw.replace(/\s+/g, '').trim();
411
728
  if (cleanUrl.length > 50 && cleanUrl.startsWith('https://')) {
412
729
  handled = true;
413
- // Copy to local clipboard via OSC 52
414
- osc52Copy(cleanUrl);
415
- // Print to stderr so it doesn't mix with the PTY output
416
- log.success('Login URL copied to your clipboard — paste it in your browser');
730
+ handleOAuthUrl(cleanUrl, { isRemote, hostPlatform, execSync });
417
731
  }
418
732
  return data;
419
733
  },
420
734
  };
421
735
  }
422
- // ── Browser-open hook for OAuth (via sandbox home) ────────
423
- /**
424
- * Detect whether we can open a browser on this machine.
425
- * Returns false for SSH sessions, headless servers, etc.
426
- */
427
- function canOpenBrowser() {
428
- if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
429
- return false;
430
- if ((0, os_1.platform)() === 'darwin')
431
- return true;
432
- if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY)
433
- return true;
434
- return false;
435
- }
436
- /**
437
- * Copy text to the user's local clipboard via OSC 52 escape sequence.
438
- * Works over SSH in terminals that support it: iTerm2, kitty, alacritty,
439
- * Windows Terminal, tmux (with set-clipboard on), foot, WezTerm, etc.
440
- */
441
- function osc52Copy(text) {
442
- const encoded = Buffer.from(text).toString('base64');
443
- // Write directly to the TTY to bypass any stdout buffering/piping
444
- const tty = process.stderr.isTTY ? process.stderr : process.stdout;
445
- tty.write(`\x1b]52;c;${encoded}\x07`);
446
- }
447
- /**
448
- * Sets up OAuth browser handling. Always intercepts the URL via BROWSER
449
- * hook. Behaviour depends on environment:
450
- *
451
- * 1. Local macOS + podman: Open URL with `open`, copy with `pbcopy`,
452
- * forward callback port via `podman machine ssh`.
453
- * 2. Local Linux with display: Open with `xdg-open`.
454
- * 3. SSH / headless: Copy URL to local clipboard via OSC 52 terminal
455
- * escape sequence. Works in iTerm2, kitty, Windows Terminal, tmux, etc.
456
- * Falls back to displaying the URL if OSC 52 is not supported.
457
- */
458
- function setupBrowserHook() {
459
- const isRemote = !canOpenBrowser();
460
- const sandboxHome = (0, config_js_1.getSandboxHome)();
736
+ function setupBrowserHook(options = {}) {
737
+ const isRemote = options.forceRemote ?? !canOpenBrowser();
738
+ const hostPlatform = options.platformOverride ?? (0, os_1.platform)();
739
+ const execSync = options.execSync ?? child_process_1.execFileSync;
740
+ const sandboxHome = options.sandboxHomeOverride ?? (0, config_js_1.getSandboxHome)();
461
741
  const labgateDir = (0, path_1.join)(sandboxHome, '.labgate');
462
742
  (0, fs_1.mkdirSync)(labgateDir, { recursive: true });
463
743
  // Write the browser-open script (runs inside the container)
464
744
  const scriptPath = (0, path_1.join)(labgateDir, 'browser-open.sh');
465
- (0, fs_1.writeFileSync)(scriptPath, '#!/bin/sh\necho "$1" > /home/sandbox/.labgate/browser-url\n', { mode: 0o755 });
745
+ (0, fs_1.writeFileSync)(scriptPath, [
746
+ '#!/bin/sh',
747
+ 'set -eu',
748
+ 'url=""',
749
+ 'for arg in "$@"; do',
750
+ ' case "$arg" in',
751
+ ' http://*|https://*) url="$arg" ;;',
752
+ ' esac',
753
+ 'done',
754
+ '[ -n "$url" ] || url="${1:-}"',
755
+ 'mkdir -p /home/sandbox/.labgate',
756
+ 'printf \'%s\\n\' "$url" > /home/sandbox/.labgate/browser-url',
757
+ 'exit 0',
758
+ '',
759
+ ].join('\n'), { mode: 0o755 });
466
760
  // Remove stale URL file
467
761
  const urlFilePath = (0, path_1.join)(labgateDir, 'browser-url');
468
762
  try {
@@ -470,62 +764,48 @@ function setupBrowserHook() {
470
764
  }
471
765
  catch { /* doesn't exist */ }
472
766
  let handled = false;
473
- // Watch for the URL file on the host side
474
- const watcher = (0, fs_1.watch)(labgateDir, (_eventType, filename) => {
475
- if (handled || filename !== 'browser-url')
476
- return;
477
- handled = true;
767
+ const processUrlFile = () => {
768
+ if (handled)
769
+ return true;
478
770
  try {
479
771
  const url = (0, fs_1.readFileSync)(urlFilePath, 'utf-8').trim();
480
772
  if (!url)
481
- return;
482
- if (isRemote) {
483
- // SSH / headless: push URL to local clipboard via OSC 52
484
- osc52Copy(url);
485
- log.success('Login URL copied to your local clipboard via terminal');
486
- log.info('Paste it in your browser to sign in, then paste the code back here');
487
- }
488
- else if ((0, os_1.platform)() === 'darwin') {
489
- // Local macOS: open browser + copy to clipboard
490
- const portMatch = url.match(/localhost%3A(\d+)/);
491
- if (portMatch) {
492
- const port = parseInt(portMatch[1], 10);
493
- if (port > 0 && port < 65536) {
494
- try {
495
- (0, child_process_1.execFileSync)('podman', ['machine', 'ssh', '--', '-f', '-N', '-L', `${port}:localhost:${port}`], {
496
- timeout: 5000,
497
- stdio: 'ignore',
498
- });
499
- log.step(`Forwarding callback port ${port}`);
500
- }
501
- catch { /* best effort */ }
502
- }
503
- }
504
- try {
505
- (0, child_process_1.execFileSync)('pbcopy', [], { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
506
- }
507
- catch { /* best effort */ }
508
- try {
509
- (0, child_process_1.execFileSync)('open', [url]);
510
- }
511
- catch { /* best effort */ }
512
- log.success('Login URL opened in browser and copied to clipboard');
513
- }
514
- else {
515
- // Local Linux with display
516
- try {
517
- (0, child_process_1.execFileSync)('xdg-open', [url]);
518
- }
519
- catch {
520
- osc52Copy(url);
521
- log.info(`Login URL:\n${url}`);
522
- }
523
- }
773
+ return false;
774
+ handled = true;
775
+ handleOAuthUrl(url, { isRemote, hostPlatform, execSync });
776
+ return true;
524
777
  }
525
- catch { /* best effort */ }
778
+ catch {
779
+ return false;
780
+ }
781
+ };
782
+ // Watch for the URL file on the host side
783
+ const watcher = (0, fs_1.watch)(labgateDir, (_eventType, filename) => {
784
+ if (handled)
785
+ return;
786
+ const name = filename || '';
787
+ if (name !== 'browser-url')
788
+ return;
789
+ if (processUrlFile())
790
+ return;
791
+ // File may exist but not be fully flushed yet. Retry shortly.
792
+ setTimeout(() => { processUrlFile(); }, 60);
526
793
  });
794
+ // Safety net: if fs.watch misses events, poll for a short window.
795
+ const poll = setInterval(() => {
796
+ if (handled) {
797
+ clearInterval(poll);
798
+ return;
799
+ }
800
+ processUrlFile();
801
+ }, 200);
802
+ const pollTimeout = setTimeout(() => clearInterval(poll), 60_000);
803
+ poll.unref?.();
804
+ pollTimeout.unref?.();
527
805
  const cleanup = () => {
528
806
  watcher.close();
807
+ clearInterval(poll);
808
+ clearTimeout(pollTimeout);
529
809
  try {
530
810
  (0, fs_1.unlinkSync)(urlFilePath);
531
811
  }
@@ -633,6 +913,13 @@ function formatStatusFooter(session, runtime, sessionId, image) {
633
913
  function logSessionStart(session, sessionId) {
634
914
  if (!session.config.audit.enabled)
635
915
  return;
916
+ const datasets = (session.config.datasets || []).map(ds => ({
917
+ path: ds.path.replace(/^~/, (0, os_1.homedir)()),
918
+ name: ds.name,
919
+ target: `/datasets/${ds.name}`,
920
+ mode: ds.mode,
921
+ ...(ds.description ? { description: ds.description } : {}),
922
+ }));
636
923
  const event = {
637
924
  timestamp: new Date().toISOString(),
638
925
  session: sessionId,
@@ -648,8 +935,9 @@ function logSessionStart(session, sessionId) {
648
935
  mode: p.mode,
649
936
  })),
650
937
  ],
938
+ ...(datasets.length > 0 ? { datasets } : {}),
651
939
  };
652
- (0, audit_js_1.writeAuditEvent)(session.config, event);
940
+ (0, audit_js_1.writeAuditEvent)(session.config, event, { sharedAuditDir: session.sharedAuditDir });
653
941
  }
654
942
  function logSessionEnd(session, sessionId, exitCode) {
655
943
  if (!session.config.audit.enabled)
@@ -659,7 +947,7 @@ function logSessionEnd(session, sessionId, exitCode) {
659
947
  session: sessionId,
660
948
  event: 'session_end',
661
949
  exit_code: exitCode,
662
- });
950
+ }, { sharedAuditDir: session.sharedAuditDir });
663
951
  }
664
952
  function setupSessionTimeout(session, sessionId, runtime, isExited, killChild) {
665
953
  const timeoutHours = session.config.session_timeout_hours;
@@ -676,7 +964,7 @@ function setupSessionTimeout(session, sessionId, runtime, isExited, killChild) {
676
964
  session: sessionId,
677
965
  event: 'session_timeout',
678
966
  timeout_hours: timeoutHours,
679
- });
967
+ }, { sharedAuditDir: session.sharedAuditDir });
680
968
  }
681
969
  if (!(0, runtime_js_1.isApptainerFamily)(runtime)) {
682
970
  try {
@@ -703,12 +991,12 @@ function printSessionInfo(session, sessionId, runtime) {
703
991
  ['Network', mode],
704
992
  ['Timeout', timeoutLabel],
705
993
  ['Blocked', `${session.config.filesystem.blocked_patterns.length} patterns`],
994
+ ...((session.config.datasets || []).length > 0 ? [['Datasets', `${session.config.datasets.length} mounted`]] : []),
706
995
  ...(session.config.audit.enabled ? [['Audit', (0, config_js_1.getLogDir)(session.config)]] : []),
707
- ['Settings', `http://${(0, os_1.hostname)()}:7700`],
708
996
  ]);
709
997
  console.error('');
710
998
  if ((0, runtime_js_1.isApptainerFamily)(runtime) && mode !== 'host') {
711
- log.warn(`Apptainer does not enforce network=${mode}. Use podman for strict isolation.`);
999
+ log.warn(`Apptainer does not enforce network=${mode}. Use network policy on the host for strict isolation.`);
712
1000
  }
713
1001
  if (mode === 'filtered') {
714
1002
  log.warn('Filtered mode relies on your proxy. Set LABGATE_PROXY or HTTP_PROXY.');
@@ -742,65 +1030,154 @@ async function startSession(session) {
742
1030
  if (!session.dryRun) {
743
1031
  ensureOciImage(runtime, image);
744
1032
  }
745
- args = buildPodmanArgs(session, runtime, sessionId, [...tokenEnv, ...(browserHook?.env ?? [])]);
1033
+ args = buildDockerArgs(session, sessionId, [...tokenEnv, ...(browserHook?.env ?? [])]);
746
1034
  }
747
1035
  if (session.dryRun) {
748
1036
  prettyPrintCommand(runtime, args);
749
1037
  return;
750
1038
  }
751
- // Create OAuth URL interceptor for SSH/headless environments
752
- const oauthInterceptor = !canOpenBrowser() ? createOAuthInterceptor() : null;
1039
+ // Create OAuth URL interceptor as a fallback when BROWSER hook does not fire.
1040
+ // This parses Claude output and handles wrapped OAuth URLs.
1041
+ const oauthInterceptor = (session.agent === 'claude' && tokenEnv.length === 0)
1042
+ ? createOAuthInterceptor()
1043
+ : null;
1044
+ // Clean up stale session files from crashed processes, then register this session
1045
+ cleanStaleSessionFiles();
1046
+ writeSessionFile(sessionId, session, image);
1047
+ const cleanupLabgateInstruction = installLabgateInstruction(session);
1048
+ // Start SLURM job tracking if enabled
1049
+ let sessionSlurmDB = null;
1050
+ let sessionSlurmPoller = null;
1051
+ const cleanupSlurm = () => {
1052
+ if (sessionSlurmPoller) {
1053
+ sessionSlurmPoller.stop();
1054
+ sessionSlurmPoller = null;
1055
+ }
1056
+ if (sessionSlurmDB) {
1057
+ try {
1058
+ sessionSlurmDB.close();
1059
+ }
1060
+ catch { }
1061
+ sessionSlurmDB = null;
1062
+ }
1063
+ };
1064
+ if (session.config.slurm.enabled) {
1065
+ try {
1066
+ sessionSlurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
1067
+ sessionSlurmPoller = new slurm_poller_js_1.SlurmPoller({
1068
+ db: sessionSlurmDB,
1069
+ pollIntervalMs: session.config.slurm.poll_interval_seconds * 1000,
1070
+ sacctLookbackHours: session.config.slurm.sacct_lookback_hours,
1071
+ });
1072
+ sessionSlurmPoller.start();
1073
+ }
1074
+ catch {
1075
+ // SLURM tracking unavailable (better-sqlite3 not installed)
1076
+ cleanupSlurm();
1077
+ }
1078
+ }
1079
+ // Auto-register SLURM MCP server for Claude Code
1080
+ if (session.config.slurm.enabled && session.config.slurm.mcp_server && session.agent.toLowerCase() === 'claude') {
1081
+ try {
1082
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
1083
+ const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
1084
+ (0, config_js_1.ensurePrivateDir)(claudeDir);
1085
+ // The MCP server script path (compiled dist)
1086
+ const mcpServerPath = (0, path_1.resolve)(__dirname, 'slurm-mcp.js');
1087
+ const dbPath = (0, config_js_1.getSlurmDbPath)();
1088
+ // Read existing mcp.json or start fresh
1089
+ const mcpConfigPath = (0, path_1.join)(claudeDir, 'mcp.json');
1090
+ let mcpConfig = {};
1091
+ try {
1092
+ if ((0, fs_1.existsSync)(mcpConfigPath)) {
1093
+ mcpConfig = JSON.parse((0, fs_1.readFileSync)(mcpConfigPath, 'utf-8'));
1094
+ }
1095
+ }
1096
+ catch { /* start fresh */ }
1097
+ if (!mcpConfig.mcpServers)
1098
+ mcpConfig.mcpServers = {};
1099
+ mcpConfig.mcpServers['labgate-slurm'] = {
1100
+ command: 'node',
1101
+ args: [mcpServerPath, '--db', dbPath],
1102
+ };
1103
+ (0, fs_1.writeFileSync)(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
1104
+ (0, config_js_1.ensurePrivateFile)(mcpConfigPath);
1105
+ }
1106
+ catch {
1107
+ // Best effort — MCP registration failure is non-fatal
1108
+ }
1109
+ }
753
1110
  logSessionStart(session, sessionId);
754
1111
  printSessionInfo(session, sessionId, runtime);
755
- // Start settings UI server alongside the session
756
- const uiServer = (0, ui_js_1.startUI)(7700, false);
757
1112
  if (footerMode === 'once') {
758
1113
  console.log(footerLine);
759
1114
  }
760
1115
  const wantsSticky = footerMode === 'sticky';
761
- if (wantsSticky) {
762
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
763
- log.step('Sticky footer needs a TTY; using one-time footer.');
1116
+ const needsOAuthPtyFallback = !!oauthInterceptor;
1117
+ const hasTty = !!(process.stdout.isTTY && process.stdin.isTTY);
1118
+ const shouldUsePty = hasTty && (wantsSticky || needsOAuthPtyFallback);
1119
+ if (shouldUsePty) {
1120
+ const pty = await loadPty();
1121
+ if (!pty) {
1122
+ if (wantsSticky) {
1123
+ log.step('Sticky footer requires node-pty. Using one-time footer.');
1124
+ }
1125
+ else if (needsOAuthPtyFallback) {
1126
+ log.step('OAuth URL fallback interceptor unavailable (node-pty missing).');
1127
+ }
764
1128
  }
765
1129
  else {
766
- const pty = await loadPty();
767
- if (!pty) {
768
- log.step('Sticky footer requires node-pty. Using one-time footer.');
1130
+ let runtimePath;
1131
+ try {
1132
+ runtimePath = (0, child_process_1.execFileSync)('which', [runtime], { encoding: 'utf-8' }).trim();
769
1133
  }
770
- else {
771
- let runtimePath;
772
- try {
773
- runtimePath = (0, child_process_1.execFileSync)('which', [runtime], { encoding: 'utf-8' }).trim();
774
- }
775
- catch {
776
- runtimePath = runtime;
777
- }
778
- const cleanEnv = {};
779
- for (const [k, v] of Object.entries(process.env)) {
780
- if (v !== undefined)
781
- cleanEnv[k] = v;
782
- }
783
- const cols = process.stdout.columns || 80;
784
- const rows = process.stdout.rows || 24;
785
- const child = pty.spawn(runtimePath, args, {
1134
+ catch {
1135
+ runtimePath = runtime;
1136
+ }
1137
+ const cleanEnv = {};
1138
+ for (const [k, v] of Object.entries(process.env)) {
1139
+ if (v !== undefined)
1140
+ cleanEnv[k] = v;
1141
+ }
1142
+ const cols = process.stdout.columns || 80;
1143
+ const rows = process.stdout.rows || 24;
1144
+ let child;
1145
+ try {
1146
+ child = pty.spawn(runtimePath, args, {
786
1147
  name: 'xterm-256color',
787
1148
  cols,
788
1149
  rows,
789
1150
  cwd: process.cwd(),
790
1151
  env: cleanEnv,
791
1152
  });
1153
+ }
1154
+ catch (err) {
1155
+ log.step(`PTY spawn failed (${err?.message ?? String(err)}). Falling back to standard spawn.`);
1156
+ // Fall through to standard spawn path below.
1157
+ child = null;
1158
+ }
1159
+ if (!child) {
1160
+ // Continue below with non-PTY spawn.
1161
+ }
1162
+ else {
792
1163
  let exited = false;
793
1164
  const resizeHandler = () => {
794
1165
  child.resize(process.stdout.columns || 80, process.stdout.rows || 24);
795
- renderStickyFooter(footerLine);
1166
+ if (wantsSticky) {
1167
+ renderStickyFooter(footerLine);
1168
+ }
796
1169
  };
797
1170
  process.stdout.on('resize', resizeHandler);
798
- renderStickyFooter(footerLine);
1171
+ if (wantsSticky) {
1172
+ renderStickyFooter(footerLine);
1173
+ }
799
1174
  child.onData((data) => {
800
1175
  if (oauthInterceptor)
801
1176
  oauthInterceptor.feed(data);
802
1177
  process.stdout.write(data);
803
- renderStickyFooter(footerLine);
1178
+ if (wantsSticky) {
1179
+ renderStickyFooter(footerLine);
1180
+ }
804
1181
  });
805
1182
  if (process.stdin.isTTY) {
806
1183
  process.stdin.setRawMode(true);
@@ -815,7 +1192,9 @@ async function startSession(session) {
815
1192
  if (timeoutHandle)
816
1193
  clearTimeout(timeoutHandle);
817
1194
  browserHook?.cleanup();
818
- uiServer.close();
1195
+ cleanupSlurm();
1196
+ removeSessionFile(sessionId, session.sharedSessionsDir);
1197
+ cleanupLabgateInstruction();
819
1198
  if (process.stdin.isTTY) {
820
1199
  process.stdin.setRawMode(false);
821
1200
  }
@@ -830,6 +1209,12 @@ async function startSession(session) {
830
1209
  }
831
1210
  }
832
1211
  }
1212
+ else if (wantsSticky) {
1213
+ log.step('Sticky footer needs a TTY; using one-time footer.');
1214
+ }
1215
+ else if (needsOAuthPtyFallback && !hasTty) {
1216
+ log.step('OAuth URL fallback interceptor requires a TTY; relying on BROWSER hook only.');
1217
+ }
833
1218
  if (footerMode === 'sticky') {
834
1219
  console.log(footerLine);
835
1220
  }
@@ -843,7 +1228,9 @@ async function startSession(session) {
843
1228
  if (timeoutHandle)
844
1229
  clearTimeout(timeoutHandle);
845
1230
  browserHook?.cleanup();
846
- uiServer.close();
1231
+ cleanupSlurm();
1232
+ removeSessionFile(sessionId, session.sharedSessionsDir);
1233
+ cleanupLabgateInstruction();
847
1234
  logSessionEnd(session, sessionId, code ?? 0);
848
1235
  process.exit(code ?? 0);
849
1236
  });
@@ -853,36 +1240,40 @@ async function startSession(session) {
853
1240
  }
854
1241
  // ── List running sessions ─────────────────────────────────
855
1242
  async function listSessions() {
856
- const config = (0, config_js_1.loadConfig)();
857
- let runtime;
858
- try {
859
- runtime = (0, runtime_js_1.getRuntime)(config.runtime);
860
- }
861
- catch (err) {
862
- console.error(err.message ?? String(err));
863
- process.exit(1);
864
- }
865
- if ((0, runtime_js_1.isApptainerFamily)(runtime)) {
866
- log.info('Apptainer sessions run as processes. Use:');
867
- log.step('ps aux | grep apptainer');
1243
+ const dir = (0, config_js_1.getSessionsDir)();
1244
+ if (!(0, fs_1.existsSync)(dir)) {
1245
+ log.info('No active sessions.');
868
1246
  return;
869
1247
  }
870
- try {
871
- const output = (0, child_process_1.execFileSync)(runtime, [
872
- 'ps',
873
- '--filter', 'name=labgate-',
874
- '--format', '{{.Names}}\t{{.Status}}\t{{.RunningFor}}',
875
- ], { encoding: 'utf-8' }).trim();
876
- if (!output) {
877
- log.info('No active sessions.');
878
- return;
1248
+ const localHost = (0, os_1.hostname)();
1249
+ const files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.json'));
1250
+ const sessions = [];
1251
+ for (const file of files) {
1252
+ try {
1253
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
1254
+ // Check if process is still alive (local node only)
1255
+ if (data.node === localHost) {
1256
+ try {
1257
+ process.kill(data.pid, 0);
1258
+ }
1259
+ catch {
1260
+ (0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
1261
+ continue;
1262
+ }
1263
+ }
1264
+ sessions.push(data);
879
1265
  }
880
- log.header('Active sessions');
881
- console.log('NAME\t\t\tSTATUS\t\tRUNNING');
882
- console.log(output);
1266
+ catch { /* skip unparseable */ }
883
1267
  }
884
- catch {
1268
+ if (sessions.length === 0) {
885
1269
  log.info('No active sessions.');
1270
+ return;
1271
+ }
1272
+ log.header('Active sessions');
1273
+ console.log('ID\t\tAGENT\tNODE\t\tWORKDIR\t\t\tSTARTED');
1274
+ for (const s of sessions) {
1275
+ const started = s.started?.slice(11, 19) ?? '';
1276
+ console.log(`${s.id}\t${s.agent}\t${s.node}\t\t${s.workdir}\t${started}`);
886
1277
  }
887
1278
  }
888
1279
  // ── Stop a session ────────────────────────────────────────
@@ -912,4 +1303,85 @@ async function stopSession(id) {
912
1303
  process.exit(1);
913
1304
  }
914
1305
  }
1306
+ /**
1307
+ * Restart a running session with fresh config.
1308
+ * Stops the old process (via SIGTERM from session file), waits for cleanup,
1309
+ * then relaunches the same agent/workdir with the current config.
1310
+ */
1311
+ async function restartSession(id, opts) {
1312
+ const dir = (0, config_js_1.getSessionsDir)();
1313
+ const localHost = (0, os_1.hostname)();
1314
+ if (!(0, fs_1.existsSync)(dir)) {
1315
+ log.error(`No sessions directory found.`);
1316
+ process.exit(1);
1317
+ }
1318
+ // Find session by ID or prefix
1319
+ const files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.json'));
1320
+ let sessionData = null;
1321
+ let sessionFile = null;
1322
+ for (const file of files) {
1323
+ try {
1324
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
1325
+ if (data.id === id || data.id.startsWith(id)) {
1326
+ sessionData = data;
1327
+ sessionFile = (0, path_1.join)(dir, file);
1328
+ break;
1329
+ }
1330
+ }
1331
+ catch { /* skip unparseable */ }
1332
+ }
1333
+ if (!sessionData || !sessionFile) {
1334
+ log.error(`Session "${id}" not found.`);
1335
+ process.exit(1);
1336
+ }
1337
+ if (sessionData.node !== localHost) {
1338
+ log.error(`Session is on node "${sessionData.node}", not this host ("${localHost}").`);
1339
+ process.exit(1);
1340
+ }
1341
+ const { agent, workdir } = sessionData;
1342
+ if (!agent || !workdir) {
1343
+ log.error('Session file is missing agent or workdir.');
1344
+ process.exit(1);
1345
+ }
1346
+ log.info(`Restarting session ${sessionData.id} (${agent} in ${workdir})`);
1347
+ // 1. Stop the old session
1348
+ log.step('Stopping old session...');
1349
+ try {
1350
+ process.kill(sessionData.pid, 'SIGTERM');
1351
+ }
1352
+ catch {
1353
+ log.warn('Process already stopped.');
1354
+ }
1355
+ // 2. Wait for cleanup (up to 10 seconds)
1356
+ const deadline = Date.now() + 10_000;
1357
+ while ((0, fs_1.existsSync)(sessionFile) && Date.now() < deadline) {
1358
+ await new Promise(r => setTimeout(r, 200));
1359
+ }
1360
+ if ((0, fs_1.existsSync)(sessionFile)) {
1361
+ try {
1362
+ (0, fs_1.unlinkSync)(sessionFile);
1363
+ }
1364
+ catch { /* best effort */ }
1365
+ }
1366
+ log.success('Old session stopped.');
1367
+ if (opts.dryRun) {
1368
+ log.info('Dry run — would start new session with fresh config.');
1369
+ return;
1370
+ }
1371
+ // 3. Load fresh config and start new session
1372
+ // Use dynamic import to avoid circular dependency
1373
+ const { loadEffectiveConfig } = await import('./config.js');
1374
+ const effective = loadEffectiveConfig();
1375
+ const config = effective.config;
1376
+ log.step('Starting new session with fresh config...');
1377
+ await startSession({
1378
+ agent,
1379
+ workdir,
1380
+ config,
1381
+ dryRun: false,
1382
+ footerMode: 'once',
1383
+ sharedSessionsDir: effective.sharedSessionsDir,
1384
+ sharedAuditDir: effective.sharedAuditDir,
1385
+ });
1386
+ }
915
1387
  //# sourceMappingURL=container.js.map