labgate 0.5.42 → 0.5.44

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.
@@ -42,7 +42,9 @@ exports.imageToSifName = imageToSifName;
42
42
  exports.isUsableApptainerSif = isUsableApptainerSif;
43
43
  exports.ensureSifImage = ensureSifImage;
44
44
  exports.resolveSlurmProxyPathToHost = resolveSlurmProxyPathToHost;
45
+ exports.listenOnUnixSocket = listenOnUnixSocket;
45
46
  exports.buildCodexOauthPublishSpec = buildCodexOauthPublishSpec;
47
+ exports.describeCliStartupPhase = describeCliStartupPhase;
46
48
  exports.buildEntrypoint = buildEntrypoint;
47
49
  exports.setupBrowserHook = setupBrowserHook;
48
50
  exports.getAgentTokenEnv = getAgentTokenEnv;
@@ -1018,7 +1020,32 @@ function mapSlurmProxyArgsToHost(session, args, containerCwd) {
1018
1020
  }
1019
1021
  return { ok: true, args: mappedArgs };
1020
1022
  }
1021
- function startSlurmHostProxy(session) {
1023
+ async function listenOnUnixSocket(server, socketHostPath) {
1024
+ await new Promise((resolve, reject) => {
1025
+ const cleanup = () => {
1026
+ server.off('error', onError);
1027
+ server.off('listening', onListening);
1028
+ };
1029
+ const onError = (err) => {
1030
+ cleanup();
1031
+ reject(err);
1032
+ };
1033
+ const onListening = () => {
1034
+ cleanup();
1035
+ resolve();
1036
+ };
1037
+ server.once('error', onError);
1038
+ server.once('listening', onListening);
1039
+ try {
1040
+ server.listen(socketHostPath);
1041
+ }
1042
+ catch (err) {
1043
+ cleanup();
1044
+ reject(err);
1045
+ }
1046
+ });
1047
+ }
1048
+ async function startSlurmHostProxy(session) {
1022
1049
  const sandboxHome = (0, config_js_1.getSandboxHome)();
1023
1050
  const socketHostDir = (0, path_1.join)(sandboxHome, '.labgate', 'slurm', 'host-proxy');
1024
1051
  const socketHostPath = (0, path_1.join)(socketHostDir, 'slurm.sock');
@@ -1141,10 +1168,14 @@ function startSlurmHostProxy(session) {
1141
1168
  log.warn(`SLURM host proxy server error: ${err?.message ?? String(err)}`);
1142
1169
  });
1143
1170
  try {
1144
- server.listen(socketHostPath);
1171
+ await listenOnUnixSocket(server, socketHostPath);
1145
1172
  }
1146
1173
  catch (err) {
1147
1174
  log.warn(`Could not start SLURM host proxy: ${err?.message ?? String(err)}`);
1175
+ try {
1176
+ server.close();
1177
+ }
1178
+ catch { /* ignore */ }
1148
1179
  try {
1149
1180
  (0, fs_1.unlinkSync)(socketHostPath);
1150
1181
  }
@@ -1276,7 +1307,9 @@ const DEFAULT_CODEX_OAUTH_CALLBACK_PORT = 1455;
1276
1307
  const CODEX_OAUTH_STARTUP_HEARTBEAT_MS = 10_000;
1277
1308
  const CODEX_OAUTH_STARTUP_HEARTBEAT_MAX_MS = 50_000;
1278
1309
  const CODEX_OAUTH_STARTUP_SPINNER_MS = 125;
1279
- const CODEX_OAUTH_STARTUP_SPINNER_FRAMES = ['|', '/', '-', '\\'];
1310
+ const CLI_STARTUP_SPINNER_FRAMES = ['|', '/', '-', '\\'];
1311
+ const CLI_STARTUP_SPINNER_MS = 120;
1312
+ const CLI_STARTUP_HEARTBEAT_MS = 5_000;
1280
1313
  const DEFERRED_SLURM_PASSTHROUGH_DELAY_MS = 1_500;
1281
1314
  function getCodexOauthCallbackPort() {
1282
1315
  const raw = (process.env.LABGATE_CODEX_OAUTH_CALLBACK_PORT || '').trim();
@@ -1305,6 +1338,64 @@ function buildCodexOauthPublishSpec(port) {
1305
1338
  // Publishing without an explicit host IP gives dual-stack localhost reachability.
1306
1339
  return `${port}:${port}`;
1307
1340
  }
1341
+ function describeCliStartupPhase(agent, phase, runtime) {
1342
+ const agentLabel = String(agent || '').trim().toLowerCase() === 'codex' ? 'Codex' : 'Claude';
1343
+ if (phase === 'image') {
1344
+ if (runtime === 'apptainer')
1345
+ return `Preparing ${agentLabel} Apptainer sandbox`;
1346
+ if (runtime === 'podman')
1347
+ return `Preparing ${agentLabel} container sandbox`;
1348
+ return `Preparing ${agentLabel} sandbox`;
1349
+ }
1350
+ return `Launching ${agentLabel} inside sandbox`;
1351
+ }
1352
+ function startCliStartupHeartbeat(agent, phase, runtime) {
1353
+ const startedAt = Date.now();
1354
+ let stopped = false;
1355
+ let sawOutput = false;
1356
+ let interval = null;
1357
+ let spinnerFrame = 0;
1358
+ let currentPhase = phase;
1359
+ const useSpinner = !!(process.stderr.isTTY && process.stdout.isTTY);
1360
+ const render = () => {
1361
+ const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
1362
+ const message = describeCliStartupPhase(agent, currentPhase, runtime);
1363
+ if (useSpinner) {
1364
+ const frame = CLI_STARTUP_SPINNER_FRAMES[spinnerFrame];
1365
+ spinnerFrame = (spinnerFrame + 1) % CLI_STARTUP_SPINNER_FRAMES.length;
1366
+ process.stderr.write(`\r\x1b[2K${log.dim('›')} ${message}... ${frame} ${elapsedSeconds}s`);
1367
+ return;
1368
+ }
1369
+ log.step(`${message}... (${elapsedSeconds}s elapsed)`);
1370
+ };
1371
+ const stop = () => {
1372
+ if (stopped)
1373
+ return;
1374
+ stopped = true;
1375
+ if (interval) {
1376
+ clearInterval(interval);
1377
+ interval = null;
1378
+ }
1379
+ if (useSpinner)
1380
+ process.stderr.write('\r\x1b[2K');
1381
+ };
1382
+ const noteOutput = (data) => {
1383
+ if (stopped || sawOutput)
1384
+ return;
1385
+ if (String(data || '').length === 0)
1386
+ return;
1387
+ sawOutput = true;
1388
+ stop();
1389
+ };
1390
+ render();
1391
+ interval = setInterval(() => {
1392
+ if (stopped || sawOutput)
1393
+ return;
1394
+ render();
1395
+ }, useSpinner ? CLI_STARTUP_SPINNER_MS : CLI_STARTUP_HEARTBEAT_MS);
1396
+ interval.unref();
1397
+ return { noteOutput, stop };
1398
+ }
1308
1399
  function startCodexOauthStartupHeartbeat() {
1309
1400
  const startedAt = Date.now();
1310
1401
  let stopped = false;
@@ -1316,8 +1407,8 @@ function startCodexOauthStartupHeartbeat() {
1316
1407
  const useSpinner = !!(process.stderr.isTTY && process.stdout.isTTY);
1317
1408
  const renderSpinner = () => {
1318
1409
  const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
1319
- const frame = CODEX_OAUTH_STARTUP_SPINNER_FRAMES[spinnerFrame];
1320
- spinnerFrame = (spinnerFrame + 1) % CODEX_OAUTH_STARTUP_SPINNER_FRAMES.length;
1410
+ const frame = CLI_STARTUP_SPINNER_FRAMES[spinnerFrame];
1411
+ spinnerFrame = (spinnerFrame + 1) % CLI_STARTUP_SPINNER_FRAMES.length;
1321
1412
  process.stderr.write(`\r\x1b[2K${log.dim('›')} Waiting for Codex login output... ${frame} ${elapsedSeconds}s`);
1322
1413
  spinnerActive = true;
1323
1414
  };
@@ -2040,6 +2131,45 @@ function logStartupTimings(entries, totalMs) {
2040
2131
  const prefix = summary ? `${summary}, ` : '';
2041
2132
  log.step(`[labgate] startup timings: ${prefix}total=${formatStartupDuration(totalMs)}`);
2042
2133
  }
2134
+ function createStartupReportData(session, runtime, image) {
2135
+ const now = new Date().toISOString();
2136
+ const warnings = [];
2137
+ if (session.uiDetected === false)
2138
+ warnings.push('ui_not_detected');
2139
+ return {
2140
+ schema_version: 1,
2141
+ kind: 'labgate-startup-report',
2142
+ generated_at: now,
2143
+ updated_at: now,
2144
+ pid: process.pid,
2145
+ node: (0, os_1.hostname)(),
2146
+ agent: session.agent,
2147
+ runtime,
2148
+ workdir: session.workdir,
2149
+ image,
2150
+ dry_run: session.dryRun,
2151
+ status: session.dryRun ? 'dry-run' : 'starting',
2152
+ launch_mode: session.dryRun ? 'dry-run' : null,
2153
+ ui_detected: session.uiDetected ?? null,
2154
+ total_ms: 0,
2155
+ first_output_ms: null,
2156
+ timings_ms: {},
2157
+ cold_start_hints: [],
2158
+ warnings,
2159
+ slurm: {
2160
+ enabled: session.config.slurm.enabled,
2161
+ host_proxy_enabled: null,
2162
+ passthrough_mode: null,
2163
+ host_commands_found: null,
2164
+ staged_commands: null,
2165
+ reused_stage: null,
2166
+ },
2167
+ exit_code: null,
2168
+ };
2169
+ }
2170
+ function writeStartupReportFile(path, report) {
2171
+ (0, startup_stage_lock_js_1.writeTextFileAtomic)(path, JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
2172
+ }
2043
2173
  // ── Shared session helpers ─────────────────────────────────
2044
2174
  function logSessionStart(session, sessionId) {
2045
2175
  if (!session.config.audit.enabled)
@@ -2137,15 +2267,94 @@ function printSessionInfo(session, sessionId, runtime) {
2137
2267
  async function startSession(session) {
2138
2268
  const startupStartedAt = Date.now();
2139
2269
  const startupTimings = [];
2140
- const recordStartupTiming = (label, startedAt) => {
2141
- startupTimings.push([label, Math.max(0, Date.now() - startedAt)]);
2142
- };
2143
2270
  const preferred = session.config.runtime;
2144
2271
  const runtime = session.dryRun ? getDryRunRuntime(preferred) : (0, runtime_js_1.getRuntime)(preferred);
2145
2272
  const image = session.imageOverride ?? session.config.image;
2146
2273
  const sessionId = (0, crypto_1.randomBytes)(4).toString('hex');
2147
2274
  const footerMode = session.footerMode ?? 'sticky';
2148
2275
  const footerLine = formatStatusFooter(session, runtime, sessionId, image);
2276
+ const startupReport = session.startupReportPath
2277
+ ? createStartupReportData(session, runtime, image)
2278
+ : null;
2279
+ let startupReportWriteFailed = false;
2280
+ const flushStartupReport = () => {
2281
+ if (!startupReport || !session.startupReportPath || startupReportWriteFailed)
2282
+ return;
2283
+ startupReport.updated_at = new Date().toISOString();
2284
+ startupReport.total_ms = Math.max(0, Date.now() - startupStartedAt);
2285
+ try {
2286
+ writeStartupReportFile(session.startupReportPath, startupReport);
2287
+ }
2288
+ catch (err) {
2289
+ startupReportWriteFailed = true;
2290
+ log.warn(`Could not write startup report to ${session.startupReportPath}: ${err?.message ?? String(err)}`);
2291
+ }
2292
+ };
2293
+ const noteStartupWarning = (warning) => {
2294
+ if (!startupReport)
2295
+ return;
2296
+ if (!startupReport.warnings.includes(warning)) {
2297
+ startupReport.warnings.push(warning);
2298
+ flushStartupReport();
2299
+ }
2300
+ };
2301
+ const noteColdStartHint = (hint) => {
2302
+ if (!startupReport)
2303
+ return;
2304
+ if (!startupReport.cold_start_hints.includes(hint)) {
2305
+ startupReport.cold_start_hints.push(hint);
2306
+ flushStartupReport();
2307
+ }
2308
+ };
2309
+ const setStartupStatus = (status) => {
2310
+ if (!startupReport)
2311
+ return;
2312
+ startupReport.status = status;
2313
+ flushStartupReport();
2314
+ };
2315
+ const setStartupLaunchMode = (mode) => {
2316
+ if (!startupReport)
2317
+ return;
2318
+ startupReport.launch_mode = mode;
2319
+ flushStartupReport();
2320
+ };
2321
+ const noteStartupFirstOutput = (data) => {
2322
+ if (!startupReport)
2323
+ return;
2324
+ if (startupReport.first_output_ms !== null)
2325
+ return;
2326
+ if (String(data || '').length === 0)
2327
+ return;
2328
+ const firstOutputMs = Math.max(0, Date.now() - startupStartedAt);
2329
+ startupReport.first_output_ms = firstOutputMs;
2330
+ startupReport.timings_ms.launch_first_output = firstOutputMs;
2331
+ startupReport.status = 'running';
2332
+ flushStartupReport();
2333
+ };
2334
+ const updateSlurmStartupReport = (payload) => {
2335
+ if (!startupReport)
2336
+ return;
2337
+ if (payload.hostProxyEnabled !== undefined)
2338
+ startupReport.slurm.host_proxy_enabled = payload.hostProxyEnabled;
2339
+ if (payload.passthroughMode !== undefined)
2340
+ startupReport.slurm.passthrough_mode = payload.passthroughMode;
2341
+ if (payload.hostCommandsFound !== undefined)
2342
+ startupReport.slurm.host_commands_found = payload.hostCommandsFound;
2343
+ if (payload.stagedCommands !== undefined)
2344
+ startupReport.slurm.staged_commands = payload.stagedCommands;
2345
+ if (payload.reusedStage !== undefined)
2346
+ startupReport.slurm.reused_stage = payload.reusedStage;
2347
+ flushStartupReport();
2348
+ };
2349
+ const recordStartupTiming = (label, startedAt) => {
2350
+ const elapsedMs = Math.max(0, Date.now() - startedAt);
2351
+ startupTimings.push([label, elapsedMs]);
2352
+ if (startupReport) {
2353
+ startupReport.timings_ms[label] = elapsedMs;
2354
+ flushStartupReport();
2355
+ }
2356
+ };
2357
+ flushStartupReport();
2149
2358
  // Extract agent auth token (CLI flag → env var)
2150
2359
  const tokenEnv = session.dryRun ? [] : getAgentTokenEnv(session.agent, session.apiKey);
2151
2360
  const bridgeCodexOauthForPodman = runtime === 'podman' && shouldBridgeCodexOauthForPodman(session.agent, session.config.network.mode);
@@ -2181,6 +2390,7 @@ async function startSession(session) {
2181
2390
  let cleanupSlurmHostProxy = () => { };
2182
2391
  let cleanupDeferredSlurmPassthrough = () => { };
2183
2392
  let startDeferredSlurmPassthrough = () => { };
2393
+ let startupHeartbeat = null;
2184
2394
  // If the agent isn't installed in the persistent sandbox home yet, warn that first run can be slow.
2185
2395
  if (!session.dryRun) {
2186
2396
  const sandboxHome = (0, config_js_1.getSandboxHome)();
@@ -2188,18 +2398,28 @@ async function startSession(session) {
2188
2398
  const installedBin = (0, path_1.join)(sandboxHome, '.npm-global', 'bin', agentBin);
2189
2399
  try {
2190
2400
  if (!(0, fs_1.existsSync)(installedBin)) {
2401
+ noteColdStartHint('agent_missing_in_sandbox');
2191
2402
  log.step('First run: preparing sandbox (pulling image + installing agent). This can take a minute...');
2192
2403
  }
2193
2404
  }
2194
2405
  catch { /* ignore */ }
2406
+ if (runtime === 'apptainer') {
2407
+ try {
2408
+ const cachedSifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), imageToSifName(image));
2409
+ if (!(0, fs_1.existsSync)(cachedSifPath))
2410
+ noteColdStartHint('sif_cache_missing');
2411
+ }
2412
+ catch { /* ignore */ }
2413
+ }
2195
2414
  }
2196
2415
  // For Apptainer sessions, always try SLURM CLI passthrough so sbatch/squeue
2197
2416
  // can work out of the box when present on the host. Full SLURM tracking/MCP
2198
2417
  // remains controlled by slurm.enabled.
2199
2418
  if (!session.dryRun && runtime === 'apptainer') {
2200
2419
  const hostProxyStartedAt = Date.now();
2201
- const hostProxy = startSlurmHostProxy(session);
2420
+ const hostProxy = await startSlurmHostProxy(session);
2202
2421
  recordStartupTiming('slurm_host_proxy', hostProxyStartedAt);
2422
+ updateSlurmStartupReport({ hostProxyEnabled: !!hostProxy });
2203
2423
  const trackingEnabled = session.config.slurm.enabled;
2204
2424
  const runSlurmPassthroughStage = (mode) => {
2205
2425
  const stageStartedAt = Date.now();
@@ -2212,8 +2432,15 @@ async function startSession(session) {
2212
2432
  preferHostProxy: false,
2213
2433
  });
2214
2434
  recordStartupTiming(mode === 'startup' ? 'slurm_passthrough_stage' : 'slurm_passthrough_stage_background', stageStartedAt);
2435
+ updateSlurmStartupReport({
2436
+ passthroughMode: staged.ok ? 'staged' : 'unavailable',
2437
+ hostCommandsFound: staged.hostCommands.length,
2438
+ stagedCommands: staged.staged.length,
2439
+ reusedStage: staged.reused,
2440
+ });
2215
2441
  if (trackingEnabled) {
2216
2442
  if (!staged.ok) {
2443
+ noteStartupWarning('slurm_commands_unavailable');
2217
2444
  log.warn('SLURM is enabled but no SLURM commands were found on the host PATH. ' +
2218
2445
  'Inside-sandbox SLURM commands (squeue/sbatch/...) will be unavailable.');
2219
2446
  }
@@ -2236,6 +2463,13 @@ async function startSession(session) {
2236
2463
  }
2237
2464
  catch (err) {
2238
2465
  recordStartupTiming(mode === 'startup' ? 'slurm_passthrough_stage' : 'slurm_passthrough_stage_background', stageStartedAt);
2466
+ updateSlurmStartupReport({
2467
+ passthroughMode: 'unavailable',
2468
+ reusedStage: false,
2469
+ });
2470
+ noteStartupWarning(mode === 'startup'
2471
+ ? 'slurm_passthrough_stage_failed'
2472
+ : 'slurm_passthrough_stage_background_failed');
2239
2473
  log.warn(mode === 'startup'
2240
2474
  ? `SLURM passthrough staging failed: ${err?.message ?? String(err)}`
2241
2475
  : `Deferred SLURM passthrough staging failed: ${err?.message ?? String(err)}`);
@@ -2253,8 +2487,17 @@ async function startSession(session) {
2253
2487
  preferHostProxy: true,
2254
2488
  });
2255
2489
  recordStartupTiming('slurm_passthrough_stage', stageStartedAt);
2490
+ updateSlurmStartupReport({
2491
+ passthroughMode: staged.ok
2492
+ ? (staged.mode === 'proxy-only' ? 'proxy-only' : 'staged')
2493
+ : 'unavailable',
2494
+ hostCommandsFound: staged.hostCommands.length,
2495
+ stagedCommands: staged.staged.length,
2496
+ reusedStage: staged.reused,
2497
+ });
2256
2498
  if (trackingEnabled) {
2257
2499
  if (!staged.ok) {
2500
+ noteStartupWarning('slurm_commands_unavailable');
2258
2501
  log.warn('SLURM is enabled but no SLURM commands were found on the host PATH. ' +
2259
2502
  'Inside-sandbox SLURM commands (squeue/sbatch/...) will be unavailable.');
2260
2503
  }
@@ -2275,12 +2518,19 @@ async function startSession(session) {
2275
2518
  }
2276
2519
  }
2277
2520
  else {
2521
+ noteStartupWarning('slurm_host_proxy_unavailable');
2278
2522
  if (trackingEnabled) {
2279
2523
  log.step('SLURM host proxy unavailable. Staging SLURM passthrough before startup.');
2280
2524
  runSlurmPassthroughStage('startup');
2281
2525
  }
2282
2526
  else {
2283
2527
  log.step('SLURM host proxy unavailable. Deferring SLURM passthrough staging until after startup.');
2528
+ updateSlurmStartupReport({
2529
+ passthroughMode: 'deferred-stage',
2530
+ hostCommandsFound: null,
2531
+ stagedCommands: null,
2532
+ reusedStage: null,
2533
+ });
2284
2534
  let cancelled = false;
2285
2535
  let timer = null;
2286
2536
  let started = false;
@@ -2308,32 +2558,45 @@ async function startSession(session) {
2308
2558
  }
2309
2559
  }
2310
2560
  let args;
2311
- if (runtime === 'apptainer') {
2312
- let sifPath;
2313
- if (session.dryRun) {
2314
- sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), imageToSifName(image));
2561
+ const imagePrepareStartedAt = Date.now();
2562
+ try {
2563
+ if (runtime === 'apptainer') {
2564
+ startupHeartbeat = startCliStartupHeartbeat(session.agent, 'image', runtime);
2565
+ let sifPath;
2566
+ if (session.dryRun) {
2567
+ sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), imageToSifName(image));
2568
+ }
2569
+ else {
2570
+ sifPath = await ensureSifImage(runtime, image);
2571
+ }
2572
+ args = buildApptainerArgs(session, sifPath, sessionId, runtimeEnvArgs);
2315
2573
  }
2316
2574
  else {
2317
- sifPath = await ensureSifImage(runtime, image);
2318
- }
2319
- args = buildApptainerArgs(session, sifPath, sessionId, runtimeEnvArgs);
2320
- }
2321
- else {
2322
- if (bridgeCodexOauthForPodman) {
2323
- const port = getCodexOauthCallbackPort();
2324
- log.step(`Codex OAuth callback bridge enabled on localhost:${port} ` +
2325
- '(Podman macOS uses bridge networking for callback compatibility).');
2326
- if (needsCodexOauthStartupHeartbeat) {
2327
- log.step('Launching Codex OAuth flow. Login output may take ~30s to appear.');
2575
+ if (bridgeCodexOauthForPodman) {
2576
+ const port = getCodexOauthCallbackPort();
2577
+ log.step(`Codex OAuth callback bridge enabled on localhost:${port} ` +
2578
+ '(Podman macOS uses bridge networking for callback compatibility).');
2579
+ if (needsCodexOauthStartupHeartbeat) {
2580
+ log.step('Launching Codex OAuth flow. Login output may take ~30s to appear.');
2581
+ }
2328
2582
  }
2583
+ startupHeartbeat = startCliStartupHeartbeat(session.agent, 'image', runtime);
2584
+ if (!session.dryRun) {
2585
+ ensurePodmanImage(runtime, image);
2586
+ }
2587
+ args = buildPodmanArgs(session, image, sessionId, runtimeEnvArgs, { tty: !!(process.stdout.isTTY && process.stdin.isTTY) });
2329
2588
  }
2330
- if (!session.dryRun) {
2331
- ensurePodmanImage(runtime, image);
2332
- }
2333
- args = buildPodmanArgs(session, image, sessionId, runtimeEnvArgs, { tty: !!(process.stdout.isTTY && process.stdin.isTTY) });
2589
+ }
2590
+ finally {
2591
+ recordStartupTiming('image_prepare', imagePrepareStartedAt);
2592
+ startupHeartbeat?.stop();
2593
+ startupHeartbeat = null;
2334
2594
  }
2335
2595
  if (session.dryRun) {
2596
+ setStartupLaunchMode('dry-run');
2597
+ setStartupStatus('dry-run');
2336
2598
  prettyPrintCommand(runtime, args);
2599
+ flushStartupReport();
2337
2600
  return;
2338
2601
  }
2339
2602
  // Create OAuth URL interceptor as a fallback when BROWSER hook does not fire.
@@ -2372,6 +2635,7 @@ async function startSession(session) {
2372
2635
  sessionSlurmPoller.start();
2373
2636
  }
2374
2637
  catch (err) {
2638
+ noteStartupWarning('slurm_tracking_unavailable');
2375
2639
  log.warn(`SLURM tracking unavailable: ${err?.message ?? String(err)}`);
2376
2640
  cleanupSlurm();
2377
2641
  }
@@ -2389,7 +2653,11 @@ async function startSession(session) {
2389
2653
  const wantsSticky = footerMode === 'sticky';
2390
2654
  const needsOAuthPtyFallback = !!oauthInterceptor;
2391
2655
  const hasTty = !!(process.stdout.isTTY && process.stdin.isTTY);
2392
- const shouldUsePty = hasTty && (wantsSticky || needsOAuthPtyFallback || needsCodexOauthStartupHeartbeat);
2656
+ const wantsStartupOutputCapture = !!startupReport;
2657
+ const shouldUsePty = hasTty && (wantsSticky
2658
+ || needsOAuthPtyFallback
2659
+ || needsCodexOauthStartupHeartbeat
2660
+ || wantsStartupOutputCapture);
2393
2661
  if (shouldUsePty) {
2394
2662
  const pty = await loadPty();
2395
2663
  if (!pty) {
@@ -2402,6 +2670,10 @@ async function startSession(session) {
2402
2670
  else if (needsCodexOauthStartupHeartbeat) {
2403
2671
  log.step('Codex startup heartbeat unavailable (node-pty missing).');
2404
2672
  }
2673
+ else if (wantsStartupOutputCapture) {
2674
+ noteStartupWarning('startup_output_capture_unavailable');
2675
+ log.step('Startup output capture unavailable (node-pty missing).');
2676
+ }
2405
2677
  }
2406
2678
  else {
2407
2679
  let runtimePath;
@@ -2419,6 +2691,9 @@ async function startSession(session) {
2419
2691
  const cols = process.stdout.columns || 80;
2420
2692
  const rows = process.stdout.rows || 24;
2421
2693
  let child;
2694
+ const launchHeartbeat = startCliStartupHeartbeat(session.agent, 'launch', runtime);
2695
+ setStartupStatus('launching');
2696
+ setStartupLaunchMode('pty');
2422
2697
  try {
2423
2698
  child = pty.spawn(runtimePath, args, {
2424
2699
  name: 'xterm-256color',
@@ -2429,6 +2704,8 @@ async function startSession(session) {
2429
2704
  });
2430
2705
  }
2431
2706
  catch (err) {
2707
+ launchHeartbeat.stop();
2708
+ noteStartupWarning('pty_spawn_failed');
2432
2709
  log.step(`PTY spawn failed (${err?.message ?? String(err)}). Falling back to standard spawn.`);
2433
2710
  // Fall through to standard spawn path below.
2434
2711
  child = null;
@@ -2452,7 +2729,9 @@ async function startSession(session) {
2452
2729
  renderStickyFooter(footerLine);
2453
2730
  }
2454
2731
  child.onData((data) => {
2732
+ launchHeartbeat.noteOutput(data);
2455
2733
  codexOauthHeartbeat?.noteOutput(data);
2734
+ noteStartupFirstOutput(data);
2456
2735
  if (oauthInterceptor)
2457
2736
  oauthInterceptor.feed(data);
2458
2737
  process.stdout.write(data);
@@ -2475,7 +2754,13 @@ async function startSession(session) {
2475
2754
  const timeoutHandle = setupSessionTimeout(session, sessionId, runtime, () => exited, () => child.kill('SIGTERM'));
2476
2755
  child.onExit((event) => {
2477
2756
  exited = true;
2757
+ launchHeartbeat.stop();
2478
2758
  codexOauthHeartbeat?.stop();
2759
+ if (startupReport) {
2760
+ startupReport.exit_code = event.exitCode ?? 0;
2761
+ startupReport.status = 'exited';
2762
+ flushStartupReport();
2763
+ }
2479
2764
  if (timeoutHandle)
2480
2765
  clearTimeout(timeoutHandle);
2481
2766
  browserHook?.cleanup();
@@ -2505,17 +2790,33 @@ async function startSession(session) {
2505
2790
  else if (needsOAuthPtyFallback && !hasTty) {
2506
2791
  log.step('OAuth URL fallback interceptor requires a TTY; relying on BROWSER hook only.');
2507
2792
  }
2793
+ else if (wantsStartupOutputCapture && !hasTty) {
2794
+ noteStartupWarning('startup_output_capture_unavailable');
2795
+ log.step('Startup output capture requires a TTY; report will omit first-output timing.');
2796
+ }
2508
2797
  if (footerMode === 'sticky') {
2509
2798
  console.log(footerLine);
2510
2799
  }
2800
+ log.step(`${describeCliStartupPhase(session.agent, 'launch', runtime)}...`);
2511
2801
  console.log('');
2512
2802
  // Spawn path: must use stdio:'inherit' to preserve TTY for Claude Code.
2513
2803
  // OAuth interception relies on the BROWSER hook + file watcher here
2514
2804
  // (the PTY path above uses onData for direct interception).
2805
+ setStartupStatus('launching');
2806
+ setStartupLaunchMode('inherit');
2515
2807
  const child = (0, child_process_1.spawn)(runtime, args, { stdio: 'inherit' });
2808
+ if (startupReport && startupReport.first_output_ms === null) {
2809
+ startupReport.status = 'running';
2810
+ flushStartupReport();
2811
+ }
2516
2812
  startDeferredSlurmPassthrough();
2517
2813
  const timeoutHandle = setupSessionTimeout(session, sessionId, runtime, () => child.exitCode !== null, () => child.kill('SIGTERM'));
2518
2814
  child.on('close', (code) => {
2815
+ if (startupReport) {
2816
+ startupReport.exit_code = code ?? 0;
2817
+ startupReport.status = 'exited';
2818
+ flushStartupReport();
2819
+ }
2519
2820
  if (timeoutHandle)
2520
2821
  clearTimeout(timeoutHandle);
2521
2822
  browserHook?.cleanup();