instar 1.3.641 → 1.3.643

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 (54) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +160 -6
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +29 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/InFlightSyncOpMarker.d.ts +29 -0
  8. package/dist/core/InFlightSyncOpMarker.d.ts.map +1 -0
  9. package/dist/core/InFlightSyncOpMarker.js +149 -0
  10. package/dist/core/InFlightSyncOpMarker.js.map +1 -0
  11. package/dist/core/PostUpdateMigrator.d.ts +16 -0
  12. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  13. package/dist/core/PostUpdateMigrator.js +91 -0
  14. package/dist/core/PostUpdateMigrator.js.map +1 -1
  15. package/dist/core/SessionManager.d.ts +146 -1
  16. package/dist/core/SessionManager.d.ts.map +1 -1
  17. package/dist/core/SessionManager.js +498 -144
  18. package/dist/core/SessionManager.js.map +1 -1
  19. package/dist/core/SleepWakeDetector.d.ts +33 -0
  20. package/dist/core/SleepWakeDetector.d.ts.map +1 -1
  21. package/dist/core/SleepWakeDetector.js +53 -0
  22. package/dist/core/SleepWakeDetector.js.map +1 -1
  23. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  24. package/dist/core/devGatedFeatures.js +23 -0
  25. package/dist/core/devGatedFeatures.js.map +1 -1
  26. package/dist/core/types.d.ts +52 -0
  27. package/dist/core/types.d.ts.map +1 -1
  28. package/dist/core/types.js.map +1 -1
  29. package/dist/lifeline/ServerSupervisor.d.ts +42 -0
  30. package/dist/lifeline/ServerSupervisor.d.ts.map +1 -1
  31. package/dist/lifeline/ServerSupervisor.js +79 -4
  32. package/dist/lifeline/ServerSupervisor.js.map +1 -1
  33. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  34. package/dist/lifeline/TelegramLifeline.js +8 -0
  35. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  36. package/dist/monitoring/DegradedTmuxGuard.d.ts +161 -0
  37. package/dist/monitoring/DegradedTmuxGuard.d.ts.map +1 -0
  38. package/dist/monitoring/DegradedTmuxGuard.js +277 -0
  39. package/dist/monitoring/DegradedTmuxGuard.js.map +1 -0
  40. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  41. package/dist/monitoring/guardManifest.js +18 -0
  42. package/dist/monitoring/guardManifest.js.map +1 -1
  43. package/dist/server/routes.d.ts.map +1 -1
  44. package/dist/server/routes.js +13 -5
  45. package/dist/server/routes.js.map +1 -1
  46. package/package.json +2 -2
  47. package/scripts/lint-no-direct-destructive.js +14 -0
  48. package/scripts/lint-sync-subprocess-chokepoint.js +208 -0
  49. package/scripts/sync-subprocess-chokepoint-baseline.json +148 -0
  50. package/src/data/builtin-manifest.json +65 -65
  51. package/upgrades/1.3.642.md +46 -0
  52. package/upgrades/1.3.643.md +66 -0
  53. package/upgrades/side-effects/false-excuse-deferral-stop-guard.md +75 -0
  54. package/upgrades/side-effects/tmux-event-loop-resilience.md +86 -0
@@ -20,6 +20,7 @@ import { resolveFrameworkTranscriptPath } from './FrameworkSessionStore.js';
20
20
  import { paneShowsClaudeWorking } from './claudeActivityIndicators.js';
21
21
  import { extractGeminiFinalAssistantBlock, meaningfulTail } from './paneText.js';
22
22
  import { ensureInteractiveReady } from './ensureInteractiveReady.js';
23
+ import { withSyncOp } from './InFlightSyncOpMarker.js';
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
  import { DegradationReporter } from '../monitoring/DegradationReporter.js';
25
26
  import { detectRateLimited } from '../monitoring/rateLimitDetection.js';
@@ -289,6 +290,24 @@ export class SessionManager extends EventEmitter {
289
290
  * Used by the health endpoint to avoid synchronous tmux polling. */
290
291
  _cachedRunningCount = 0;
291
292
  _cachedRunningSessions = [];
293
+ /** (A) tmux-event-loop-resilience — the bounded async hot path.
294
+ * When false (the default + every off-path/test caller) the legacy sync hot
295
+ * path runs byte-for-byte; monitorTick never touches the async wrapper. */
296
+ tmuxAsyncEnabled;
297
+ /** (C) latency feed — optional-chained so (A) lands independently of the guard. */
298
+ degradedTmuxGuard;
299
+ /** Per-call SIGKILL-bounded timeout for the async wrapper (ms). */
300
+ tmuxCallTimeoutMs;
301
+ /** Max concurrent in-flight hot-path tmux calls; (N+1)th fails CLOSED to KEEP. */
302
+ tmuxMaxInFlight;
303
+ /** Single-flight coalescing: in-flight async tmux calls keyed by `${op}:${session}`
304
+ * so a destructive op (kill) can NEVER coalesce with a concurrent read of the
305
+ * same session (a silent correctness hole). */
306
+ tmuxInflight = new Map();
307
+ /** Live count of in-flight async tmux calls — the max-in-flight ceiling. */
308
+ tmuxInflightCount = 0;
309
+ /** Fallback ceiling when no maxInFlight is threaded from server boot. */
310
+ static TMUX_MAX_INFLIGHT_DEFAULT = 8;
292
311
  /** Worktree manager — when set, spawnSession resolves an isolated worktree per topic. */
293
312
  worktreeManager = null;
294
313
  buildContextStore = null;
@@ -312,10 +331,22 @@ export class SessionManager extends EventEmitter {
312
331
  * same derivation as the pending-inject ledger: StateManager.baseDir when
313
332
  * present, else `<projectDir>/.instar`). */
314
333
  vaultStateDir;
315
- constructor(config, state) {
334
+ constructor(config, state, opts = {}) {
316
335
  super();
317
336
  this.config = config;
318
337
  this.state = state;
338
+ // (A)/(C) tmux-event-loop-resilience wiring — resolved server-side and threaded
339
+ // in. Absent (every 2-arg/test caller) ⇒ the legacy sync hot path runs unchanged.
340
+ this.tmuxAsyncEnabled = opts.tmuxAsyncEnabled === true;
341
+ this.degradedTmuxGuard = opts.degradedTmuxGuard;
342
+ this.tmuxCallTimeoutMs =
343
+ typeof opts.tmuxCallTimeoutMs === 'number' && Number.isFinite(opts.tmuxCallTimeoutMs) && opts.tmuxCallTimeoutMs > 0
344
+ ? opts.tmuxCallTimeoutMs
345
+ : 9000;
346
+ this.tmuxMaxInFlight =
347
+ typeof opts.tmuxMaxInFlight === 'number' && Number.isFinite(opts.tmuxMaxInFlight) && opts.tmuxMaxInFlight > 0
348
+ ? opts.tmuxMaxInFlight
349
+ : SessionManager.TMUX_MAX_INFLIGHT_DEFAULT;
319
350
  // Durable in-flight inject ledger (finding 8d300555) — records survive a
320
351
  // server restart in the spawn→ready→inject window; recoverPendingInjects()
321
352
  // sweeps them at boot. Mock StateManagers in tests may lack baseDir —
@@ -363,6 +394,76 @@ export class SessionManager extends EventEmitter {
363
394
  setLivenessOracle(oracle) {
364
395
  this._livenessOracle = oracle;
365
396
  }
397
+ /**
398
+ * The SOLE funnel for hot-path tmux calls (tmux-event-loop-resilience §A).
399
+ * Tri-state, modeled on SessionLivenessOracle.classify: `success` and
400
+ * `definitely-absent` are the only POSITIVE signals; everything else —
401
+ * timeout, server-unreachable, unknown error — is `indeterminate`, and a
402
+ * caller keying a destructive action on the result MUST treat `indeterminate`
403
+ * as KEEP (never reap). A timeout becomes `indeterminate`, NEVER
404
+ * `definitely-absent`.
405
+ *
406
+ * `killSignal: 'SIGKILL'` is essential — a plain `timeout` sends SIGTERM,
407
+ * which a wedged tmux ignores, so the call would never actually return inside
408
+ * the bound. SIGKILL guarantees the 9s ceiling. Reuses the module-level
409
+ * `execFileAsync` (no second promisify). The `observeTmuxCall` calls feed the
410
+ * (C) DegradedTmuxGuard latency signal; optional-chained so (A) is independent
411
+ * of whether (C) is wired yet (until then the calls are a no-op).
412
+ */
413
+ async tmuxExecAsync(args, opts = {}) {
414
+ const timeout = opts.timeoutMs ?? this.tmuxCallTimeoutMs;
415
+ const t0 = Date.now();
416
+ try {
417
+ const { stdout } = await execFileAsync(this.config.tmuxPath, args, { timeout, killSignal: 'SIGKILL' });
418
+ this.degradedTmuxGuard?.observeTmuxCall(Date.now() - t0, 'success');
419
+ return { state: 'success', stdout };
420
+ }
421
+ catch (err) {
422
+ const e = err && typeof err === 'object'
423
+ ? err
424
+ : {};
425
+ const msg = (e.stderr || e.message || String(err)).toString();
426
+ // A definitive negative: the tmux server answered and named the session
427
+ // absent. Treated as a SUCCESSFUL probe for latency (the call returned fast
428
+ // with a real answer; it is not a slow/degraded call).
429
+ if (/can't find session|no such session|no server running|session not found/i.test(msg)) {
430
+ this.degradedTmuxGuard?.observeTmuxCall(Date.now() - t0, 'success');
431
+ return { state: 'definitely-absent', reason: msg.slice(0, 120) };
432
+ }
433
+ // Everything else is indeterminate. A SIGKILL (our timeout) sets
434
+ // killed===true / signal==='SIGKILL' — flag it as a killed-client so the
435
+ // guard counts the degraded-call signal distinctly from a generic error.
436
+ const killed = e.killed === true || e.signal === 'SIGKILL' || /SIGKILL/.test(msg);
437
+ this.degradedTmuxGuard?.observeTmuxCall(Date.now() - t0, killed ? 'killed-client' : 'indeterminate');
438
+ return { state: 'indeterminate', reason: msg.slice(0, 120) };
439
+ }
440
+ }
441
+ /**
442
+ * Single-flight + max-in-flight wrapper around `tmuxExecAsync` (§A).
443
+ * Coalesces concurrent calls per `(op, tmuxSession)` so a tick that probes the
444
+ * same session twice issues ONE subprocess; the key MUST include the op so a
445
+ * destructive op never shares an in-flight Promise with a read of the same
446
+ * session. When the live in-flight count is at the ceiling, resolves
447
+ * `{state:'indeterminate', reason:'max-inflight'}` WITHOUT spawning — fail
448
+ * CLOSED toward KEEP, never toward reap, and a hard cap on how many
449
+ * subprocesses the hot path can fan out (never MORE than today).
450
+ */
451
+ async tmuxExecCoalesced(op, tmuxSession, args, opts = {}) {
452
+ const key = `${op}:${tmuxSession}`;
453
+ const existing = this.tmuxInflight.get(key);
454
+ if (existing)
455
+ return existing;
456
+ if (this.tmuxInflightCount >= this.tmuxMaxInFlight) {
457
+ return { state: 'indeterminate', reason: 'max-inflight' };
458
+ }
459
+ this.tmuxInflightCount += 1;
460
+ const p = this.tmuxExecAsync(args, opts).finally(() => {
461
+ this.tmuxInflight.delete(key);
462
+ this.tmuxInflightCount = Math.max(0, this.tmuxInflightCount - 1);
463
+ });
464
+ this.tmuxInflight.set(key, p);
465
+ return p;
466
+ }
366
467
  /** Effective idle-at-prompt kill threshold (config override or hardcoded default) */
367
468
  get effectiveIdleKillMinutes() {
368
469
  return this.config.idlePromptKillMinutes ?? FALLBACK_IDLE_PROMPT_KILL_MINUTES;
@@ -982,7 +1083,19 @@ rm() { "${shimRunner}" rm "$@"; }
982
1083
  continue;
983
1084
  }
984
1085
  const alive = await this.isSessionAliveAsync(session.tmuxSession);
985
- if (!alive) {
1086
+ // (A) tri-state: ONLY a definitive `false` (dead) marks the session
1087
+ // completed. `'indeterminate'` (slow/timed-out tmux) does NOT — it falls
1088
+ // through to be re-checked next tick rather than reaping a live session.
1089
+ if (alive === false) {
1090
+ // CAS re-read after the await: an operator kill (or another killer)
1091
+ // can land mid-probe, so re-read the live record before mutating it —
1092
+ // mirrors terminateSession's status CAS. If it already drifted off
1093
+ // 'running', another writer owns the transition; skip this inline
1094
+ // mark-completed (which lacks terminateSession's own CAS guard).
1095
+ const fresh = this.state.getSession(session.id);
1096
+ if (!fresh || fresh.status !== 'running') {
1097
+ continue;
1098
+ }
986
1099
  // Check if this session had a pending Telegram injection that never got a response
987
1100
  const pendingInjection = this.pendingInjections.get(session.tmuxSession);
988
1101
  if (pendingInjection) {
@@ -995,13 +1108,13 @@ rm() { "${shimRunner}" rm "$@"; }
995
1108
  injectedAt: pendingInjection.injectedAt,
996
1109
  });
997
1110
  }
998
- session.status = 'completed';
999
- session.endedAt = new Date().toISOString();
1000
- this.state.saveSession(session);
1001
- this.emit('sessionComplete', session);
1111
+ fresh.status = 'completed';
1112
+ fresh.endedAt = new Date().toISOString();
1113
+ this.state.saveSession(fresh);
1114
+ this.emit('sessionComplete', fresh);
1002
1115
  continue;
1003
1116
  }
1004
- this.recordBuildContext(session.tmuxSession);
1117
+ await this.recordBuildContextMaybeAsync(session.tmuxSession);
1005
1118
  // ── Rerouted-interactive completion branch (june15-headless-spawn-
1006
1119
  // reroute, PR2 O1) ──
1007
1120
  // A rerouted session (completionMode==='pattern') never exits on task
@@ -1017,7 +1130,7 @@ rm() { "${shimRunner}" rm "$@"; }
1017
1130
  // maps only 'killed' → 'timeout'). A sentinel completion is a clean
1018
1131
  // finish, NOT a timeout.
1019
1132
  if (!this.config.protectedSessions.includes(session.tmuxSession) &&
1020
- this.detectSessionCompletion(session)) {
1133
+ await this.detectSessionCompletionMaybeAsync(session)) {
1021
1134
  console.log(`[SessionManager] Rerouted session "${session.name}" completed (sentinel detected). Reaping as success.`);
1022
1135
  await this.terminateSession(session.id, 'sentinel-complete', {
1023
1136
  finalStatus: 'completed',
@@ -1062,7 +1175,7 @@ rm() { "${shimRunner}" rm "$@"; }
1062
1175
  if (session.framework === 'gemini-cli') {
1063
1176
  const pendingInjection = this.pendingInjections.get(session.tmuxSession);
1064
1177
  if (pendingInjection) {
1065
- const pane = this.captureOutput(session.tmuxSession, 400) || '';
1178
+ const pane = (await this.captureOutputMaybeAsync(session.tmuxSession, 400)) || '';
1066
1179
  const marker = `[telegram:${pendingInjection.topicId}`;
1067
1180
  const markerIdx = pane.lastIndexOf(marker);
1068
1181
  if (markerIdx >= 0) {
@@ -1082,7 +1195,7 @@ rm() { "${shimRunner}" rm "$@"; }
1082
1195
  // Check for completion patterns even while session appears alive
1083
1196
  // (catches sessions where Claude finished but tmux is still open)
1084
1197
  if (!this.config.protectedSessions.includes(session.tmuxSession) &&
1085
- this.detectCompletion(session.tmuxSession)) {
1198
+ await this.detectCompletionMaybeAsync(session.tmuxSession)) {
1086
1199
  console.log(`[SessionManager] Session "${session.name}" completed (pattern detected). Cleaning up.`);
1087
1200
  // Emit beforeSessionKill so listeners (TopicResumeMap, SlackAdapter) can save resume UUIDs
1088
1201
  this.emit('beforeSessionKill', session);
@@ -1117,9 +1230,9 @@ rm() { "${shimRunner}" rm "$@"; }
1117
1230
  const limit = maxMinutes + buffer;
1118
1231
  if (elapsed > limit && !this.config.protectedSessions.includes(session.tmuxSession)) {
1119
1232
  // Activity check — defer kill if the session is doing real work.
1120
- const ageGateOutput = this.captureMeaningfulTail(session.tmuxSession, 5);
1233
+ const ageGateOutput = await this.captureMeaningfulTailMaybeAsync(session.tmuxSession, 5);
1121
1234
  const ageGateIsIdle = ageGateOutput && IDLE_PROMPT_PATTERNS.some(p => ageGateOutput.includes(p));
1122
- const ageGateHasProcs = this.hasActiveProcesses(session.tmuxSession);
1235
+ const ageGateHasProcs = await this.hasActiveProcessesMaybeAsync(session.tmuxSession);
1123
1236
  // Transcript-activity backstop (2026-06-13 incident): the pane+procs
1124
1237
  // check is BLIND to MCP/tool work. A session driving Playwright (or any
1125
1238
  // MCP server) runs that work OUT of the tmux process tree, and between
@@ -1199,17 +1312,17 @@ rm() { "${shimRunner}" rm "$@"; }
1199
1312
  // Skip sessions the SessionReaper has leased for a two-phase reap — it
1200
1313
  // is the single actor on them while reaping (§3.6).
1201
1314
  if (!this.config.protectedSessions.includes(session.tmuxSession) && !this.isReaping(session.id)) {
1202
- const output = this.captureMeaningfulTail(session.tmuxSession, 5);
1315
+ const output = await this.captureMeaningfulTailMaybeAsync(session.tmuxSession, 5);
1203
1316
  const isIdleAtPrompt = output && IDLE_PROMPT_PATTERNS.some(p => output.includes(p));
1204
1317
  // ── Prompt Gate: feed captured output to InputDetector ──
1205
1318
  if (this.promptDetector && output) {
1206
- const fullOutput = this.captureOutput(session.tmuxSession, 50);
1319
+ const fullOutput = await this.captureOutputMaybeAsync(session.tmuxSession, 50);
1207
1320
  if (fullOutput) {
1208
1321
  this.promptDetector.onCapture(session.tmuxSession, fullOutput);
1209
1322
  }
1210
1323
  }
1211
1324
  // Two conditions must BOTH be true for idle: prompt pattern + no active processes
1212
- const isActuallyIdle = isIdleAtPrompt && !this.hasActiveProcesses(session.tmuxSession);
1325
+ const isActuallyIdle = isIdleAtPrompt && !(await this.hasActiveProcessesMaybeAsync(session.tmuxSession));
1213
1326
  if (isActuallyIdle) {
1214
1327
  const now = Date.now();
1215
1328
  if (!this.idlePromptSince.has(session.id)) {
@@ -1220,11 +1333,11 @@ rm() { "${shimRunner}" rm "$@"; }
1220
1333
  // the Enter key sent after the paste end sequence doesn't register.
1221
1334
  // Re-send Enter to unstick it. Only try once per session to avoid loops.
1222
1335
  if (!this.pasteRetried.has(session.id)) {
1223
- const recentForPaste = this.captureOutput(session.tmuxSession, 15);
1336
+ const recentForPaste = await this.captureOutputMaybeAsync(session.tmuxSession, 15);
1224
1337
  if (recentForPaste && /\[Pasted text #\d+\]/.test(recentForPaste)) {
1225
1338
  this.pasteRetried.add(session.id);
1226
1339
  console.log(`[SessionManager] Session "${session.name}" has unsubmitted pasted text — resending Enter.`);
1227
- this.sendKey(session.tmuxSession, 'Enter');
1340
+ await this.sendKeyMaybeAsync(session.tmuxSession, 'Enter');
1228
1341
  this.idlePromptSince.delete(session.id); // Reset idle timer
1229
1342
  continue; // Skip to next session
1230
1343
  }
@@ -1234,7 +1347,7 @@ rm() { "${shimRunner}" rm "$@"; }
1234
1347
  // inject a nudge to get it working again instead of waiting 15m to kill.
1235
1348
  const nudgeTotal = this.errorNudgeTotal.get(session.id) ?? 0;
1236
1349
  if (shouldErrorNudge(this.errorNudgedSessions.has(session.id), nudgeTotal)) {
1237
- const recentOutput = this.captureOutput(session.tmuxSession, 30);
1350
+ const recentOutput = await this.captureOutputMaybeAsync(session.tmuxSession, 30);
1238
1351
  if (recentOutput) {
1239
1352
  // Server-side throttle ("Server is temporarily limiting
1240
1353
  // requests · not your usage limit") gets a DIFFERENT path:
@@ -1644,7 +1757,11 @@ rm() { "${shimRunner}" rm "$@"; }
1644
1757
  ? ((this.config.anthropicApiKey ?? '') !== '' ? 'env' : 'store')
1645
1758
  : undefined;
1646
1759
  try {
1647
- execFileSync(this.config.tmuxPath, [
1760
+ // §B in-flight marker: this spawn is EXCLUDED from §A async conversion
1761
+ // (its synchronous timing is part of the spawn contract) but still funnels
1762
+ // through withSyncOp so a drift during the blocking spawn is classed as an
1763
+ // event-loop BLOCK, not sleep.
1764
+ withSyncOp(() => execFileSync(this.config.tmuxPath, [
1648
1765
  'new-session', '-d',
1649
1766
  '-s', tmuxSession,
1650
1767
  '-c', resolvedCwd,
@@ -1684,12 +1801,12 @@ rm() { "${shimRunner}" rm "$@"; }
1684
1801
  '-e', 'DATABASE_URL_DEV=',
1685
1802
  '-e', 'DATABASE_URL_TEST=',
1686
1803
  ...headlessSpec.argv,
1687
- ], { encoding: 'utf-8' });
1804
+ ], { encoding: 'utf-8' }));
1688
1805
  // Increase tmux scrollback buffer for dashboard history support
1689
1806
  try {
1690
- execFileSync(this.config.tmuxPath, [
1807
+ withSyncOp(() => execFileSync(this.config.tmuxPath, [
1691
1808
  'set-option', '-t', `=${tmuxSession}:`, 'history-limit', '50000',
1692
- ], { encoding: 'utf-8', timeout: 5000 });
1809
+ ], { encoding: 'utf-8', timeout: 5000 }));
1693
1810
  }
1694
1811
  catch {
1695
1812
  // @silent-fallback-ok — history-limit is a nice-to-have
@@ -1901,7 +2018,10 @@ rm() { "${shimRunner}" rm "$@"; }
1901
2018
  // predicate as the env block above (this lane is always claude-code → always set).
1902
2019
  const reroutedCredentialSource = (this.config.anthropicApiKey ?? '') !== '' ? 'env' : 'store';
1903
2020
  try {
1904
- execFileSync(this.config.tmuxPath, [
2021
+ // §B: excluded from §A async conversion (spawn timing is contractual) but
2022
+ // funnels through withSyncOp so a drift during this blocking spawn reads as
2023
+ // an event-loop BLOCK, not sleep.
2024
+ withSyncOp(() => execFileSync(this.config.tmuxPath, [
1905
2025
  'new-session', '-d',
1906
2026
  '-s', tmuxSession,
1907
2027
  '-c', resolvedCwd,
@@ -1941,11 +2061,11 @@ rm() { "${shimRunner}" rm "$@"; }
1941
2061
  '-e', 'DATABASE_URL_DEV=',
1942
2062
  '-e', 'DATABASE_URL_TEST=',
1943
2063
  ...launchSpec.argv,
1944
- ], { encoding: 'utf-8' });
2064
+ ], { encoding: 'utf-8' }));
1945
2065
  try {
1946
- execFileSync(this.config.tmuxPath, [
2066
+ withSyncOp(() => execFileSync(this.config.tmuxPath, [
1947
2067
  'set-option', '-t', `=${tmuxSession}:`, 'history-limit', '50000',
1948
- ], { encoding: 'utf-8', timeout: 5000 });
2068
+ ], { encoding: 'utf-8', timeout: 5000 }));
1949
2069
  }
1950
2070
  catch {
1951
2071
  // @silent-fallback-ok — history-limit is a nice-to-have
@@ -2031,7 +2151,7 @@ rm() { "${shimRunner}" rm "$@"; }
2031
2151
  return false;
2032
2152
  // Verify Claude process is running inside the tmux session
2033
2153
  try {
2034
- const paneInfo = execFileSync(this.config.tmuxPath, ['display-message', '-t', `=${tmuxSession}:`, '-p', '#{pane_current_command}||#{pane_start_command}'], { encoding: 'utf-8', timeout: 5000 }).trim();
2154
+ const paneInfo = withSyncOp(() => execFileSync(this.config.tmuxPath, ['display-message', '-t', `=${tmuxSession}:`, '-p', '#{pane_current_command}||#{pane_start_command}'], { encoding: 'utf-8', timeout: 5000 })).trim();
2035
2155
  const [paneCmd, startCmd] = paneInfo.split('||');
2036
2156
  // Claude Code runs as 'claude' or 'node' process
2037
2157
  if (paneCmd && (paneCmd.includes('claude') || paneCmd.includes('node'))) {
@@ -2057,6 +2177,31 @@ rm() { "${shimRunner}" rm "$@"; }
2057
2177
  return true;
2058
2178
  }
2059
2179
  }
2180
+ /**
2181
+ * Pure classification of a `pane_current_command||pane_start_command` reading
2182
+ * into alive/dead — shared by the SYNC `isSessionAlive`, the legacy async
2183
+ * body, and the tri-state async body so the bare-shell/claude/node logic can
2184
+ * NEVER drift across the three callers. Returns the same verdicts as the
2185
+ * original inline code, including the bare-shell log line.
2186
+ */
2187
+ classifyPaneCommand(tmuxSession, paneInfo) {
2188
+ const [paneCmd, startCmd] = paneInfo.split('||');
2189
+ if (paneCmd && (paneCmd.includes('claude') || paneCmd.includes('node'))) {
2190
+ return true;
2191
+ }
2192
+ if (paneCmd === 'bash' || paneCmd === 'zsh' || paneCmd === 'sh') {
2193
+ if (startCmd && startCmd !== paneCmd) {
2194
+ return true;
2195
+ }
2196
+ console.log(`[SessionManager] Session "${tmuxSession}" has bare shell (${paneCmd}) — marking dead. start_command: ${startCmd}`);
2197
+ return false;
2198
+ }
2199
+ // Unknown command — log it and assume alive
2200
+ if (paneCmd) {
2201
+ console.log(`[SessionManager] Session "${tmuxSession}" has unknown pane command: "${paneCmd}" — assuming alive`);
2202
+ }
2203
+ return true;
2204
+ }
2060
2205
  /**
2061
2206
  * Check if a session is still running by checking tmux AND verifying
2062
2207
  * that the Claude process is running inside (async version).
@@ -2065,41 +2210,59 @@ rm() { "${shimRunner}" rm "$@"; }
2065
2210
  * Previously only checked `tmux has-session` which missed zombie sessions
2066
2211
  * where tmux was alive but Claude had exited — causing stuck sessions
2067
2212
  * that blocked the scheduler for hours.
2213
+ *
2214
+ * (A) tmux-event-loop-resilience: when `tmuxAsyncEnabled`, this returns a
2215
+ * TRI-STATE — `boolean | 'indeterminate'` — through the bounded, SIGKILL-
2216
+ * capped wrapper. A slow/timed-out `has-session` resolves to `'indeterminate'`
2217
+ * (the caller must NOT reap), NEVER to `false` ("dead"). This closes the
2218
+ * latent spurious-reap bug where a busy tmux's timeout was mapped to `false`
2219
+ * → marked-completed → killed a live session. When OFF, the legacy body runs
2220
+ * byte-for-byte (including the false-on-timeout reap).
2068
2221
  */
2069
2222
  async isSessionAliveAsync(tmuxSession) {
2070
- try {
2071
- await execFileAsync(this.config.tmuxPath, ['has-session', '-t', `=${tmuxSession}`], {
2072
- timeout: 5000,
2073
- });
2074
- }
2075
- catch {
2076
- // tmux session doesn't exist — it's dead
2077
- return false;
2078
- }
2079
- // Verify Claude process is alive inside (matches sync isSessionAlive logic)
2080
- try {
2081
- const { stdout } = await execFileAsync(this.config.tmuxPath, ['display-message', '-t', `=${tmuxSession}:`, '-p', '#{pane_current_command}||#{pane_start_command}'], { timeout: 5000 });
2082
- const paneInfo = stdout.trim();
2083
- const [paneCmd, startCmd] = paneInfo.split('||');
2084
- if (paneCmd && (paneCmd.includes('claude') || paneCmd.includes('node'))) {
2085
- return true;
2223
+ if (!this.tmuxAsyncEnabled) {
2224
+ // ── Legacy body, byte-for-byte (today's false-on-timeout behavior). ──
2225
+ try {
2226
+ await execFileAsync(this.config.tmuxPath, ['has-session', '-t', `=${tmuxSession}`], {
2227
+ timeout: 5000,
2228
+ });
2086
2229
  }
2087
- if (paneCmd === 'bash' || paneCmd === 'zsh' || paneCmd === 'sh') {
2088
- if (startCmd && startCmd !== paneCmd) {
2089
- return true;
2090
- }
2091
- console.log(`[SessionManager] Session "${tmuxSession}" has bare shell (${paneCmd}) — marking dead. start_command: ${startCmd}`);
2230
+ catch {
2231
+ // tmux session doesn't exist it's dead
2092
2232
  return false;
2093
2233
  }
2094
- // Unknown command — log it and assume alive
2095
- if (paneCmd) {
2096
- console.log(`[SessionManager] Session "${tmuxSession}" has unknown pane command: "${paneCmd}" — assuming alive`);
2234
+ try {
2235
+ const { stdout } = await execFileAsync(this.config.tmuxPath, ['display-message', '-t', `=${tmuxSession}:`, '-p', '#{pane_current_command}||#{pane_start_command}'], { timeout: 5000 });
2236
+ return this.classifyPaneCommand(tmuxSession, stdout.trim());
2237
+ }
2238
+ catch {
2239
+ // @silent-fallback-ok: legacy off-path body, byte-for-byte. An unprobeable
2240
+ // display-message (the session exists but the pane read failed/timed out) ⇒ assume
2241
+ // ALIVE — the safe direction (never spuriously reap a live session over a probe blip).
2242
+ return true;
2097
2243
  }
2098
- return true;
2099
- }
2100
- catch {
2101
- return true;
2102
2244
  }
2245
+ // ── (A) tri-state path. ──
2246
+ const has = await this.tmuxExecCoalesced('has-session', tmuxSession, [
2247
+ 'has-session',
2248
+ '-t',
2249
+ `=${tmuxSession}`,
2250
+ ]);
2251
+ if (has.state === 'definitely-absent')
2252
+ return false; // genuinely dead
2253
+ if (has.state === 'indeterminate')
2254
+ return 'indeterminate'; // slow tmux ⇒ NEVER 'dead'
2255
+ // Verify Claude process is alive inside (matches sync isSessionAlive logic).
2256
+ const disp = await this.tmuxExecCoalesced('display-message', tmuxSession, [
2257
+ 'display-message',
2258
+ '-t',
2259
+ `=${tmuxSession}:`,
2260
+ '-p',
2261
+ '#{pane_current_command}||#{pane_start_command}',
2262
+ ]);
2263
+ if (disp.state !== 'success')
2264
+ return true; // unprobeable ⇒ assume alive (preserves the legacy catch)
2265
+ return this.classifyPaneCommand(tmuxSession, disp.stdout.trim());
2103
2266
  }
2104
2267
  /**
2105
2268
  * Kill a session by terminating its tmux session.
@@ -2126,9 +2289,11 @@ rm() { "${shimRunner}" rm "$@"; }
2126
2289
  // while the session is still alive.
2127
2290
  this.emit('beforeSessionKill', session);
2128
2291
  try {
2129
- execFileSync(this.config.tmuxPath, ['kill-session', '-t', `=${session.tmuxSession}`], {
2292
+ // §B: kill is EXCLUDED from §A async conversion but funnels through
2293
+ // withSyncOp so a drift during the blocking kill reads as a block, not sleep.
2294
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['kill-session', '-t', `=${session.tmuxSession}`], {
2130
2295
  encoding: 'utf-8',
2131
- });
2296
+ }));
2132
2297
  }
2133
2298
  catch {
2134
2299
  // Session might already be dead
@@ -2158,58 +2323,147 @@ rm() { "${shimRunner}" rm "$@"; }
2158
2323
  * terminal output patterns, topic bindings, or subagent trackers. If the process
2159
2324
  * tree shows work happening, the session is active. Period.
2160
2325
  */
2326
+ /**
2327
+ * Pure descendant-tree analysis — shared by the sync `hasActiveProcesses` and
2328
+ * its async twin so the parsing/tree-walk/baseline-filter logic can NEVER
2329
+ * drift between paths (Map 2 risk #5). Given the pane shell PID and raw
2330
+ * `ps -eo pid,ppid,command` output, returns whether any non-baseline,
2331
+ * non-Claude descendant process is running.
2332
+ */
2333
+ computeHasActiveProcesses(panePid, psOutput) {
2334
+ // Build a map of PID → { ppid, command }
2335
+ const processes = new Map();
2336
+ for (const line of psOutput.split('\n').slice(1)) { // skip header
2337
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
2338
+ if (match) {
2339
+ processes.set(match[1], { ppid: match[2], command: match[3] });
2340
+ }
2341
+ }
2342
+ // Walk the tree: find all descendants of panePid
2343
+ const descendants = [];
2344
+ const queue = [panePid];
2345
+ while (queue.length > 0) {
2346
+ const parentPid = queue.shift();
2347
+ for (const [pid, info] of processes) {
2348
+ if (info.ppid === parentPid && pid !== panePid) {
2349
+ descendants.push({ pid, command: info.command });
2350
+ queue.push(pid);
2351
+ }
2352
+ }
2353
+ }
2354
+ // Filter out baseline processes
2355
+ const activeProcesses = descendants.filter(p => {
2356
+ return !BASELINE_PROCESS_PATTERNS.some(pattern => pattern.test(p.command));
2357
+ });
2358
+ // The Claude Code node process itself is always running — that's the main process.
2359
+ // We care about processes BEYOND Claude itself and its baseline children.
2360
+ // Claude's main process is the direct child of the pane PID.
2361
+ // Filter it out: it's typically `node` or `claude` running the main Claude binary.
2362
+ const nonClaude = activeProcesses.filter(p => {
2363
+ const proc = processes.get(p.pid);
2364
+ // Direct child of pane PID running claude/node is the main process
2365
+ if (proc?.ppid === panePid) {
2366
+ return !/\bclaude\b/.test(p.command) && !/\bnode\b.*\bclaude\b/.test(p.command);
2367
+ }
2368
+ return true;
2369
+ });
2370
+ return nonClaude.length > 0;
2371
+ }
2161
2372
  hasActiveProcesses(tmuxSession) {
2162
2373
  try {
2163
2374
  // Get the tmux pane's shell PID
2164
- const panePid = execFileSync(this.config.tmuxPath, ['list-panes', '-t', `=${tmuxSession}:`, '-F', '#{pane_pid}'], { encoding: 'utf-8', timeout: 5000 }).trim();
2375
+ const panePid = withSyncOp(() => execFileSync(this.config.tmuxPath, ['list-panes', '-t', `=${tmuxSession}:`, '-F', '#{pane_pid}'], { encoding: 'utf-8', timeout: 5000 })).trim();
2165
2376
  if (!panePid || !/^\d+$/.test(panePid))
2166
2377
  return false;
2167
2378
  // Get all descendant processes of the pane PID
2168
2379
  // Use ps to find all processes whose parent is in our tree
2169
- const psOutput = execFileSync('ps', ['-eo', 'pid,ppid,command'], { encoding: 'utf-8', timeout: 5000 });
2170
- // Build a map of PID → { ppid, command }
2171
- const processes = new Map();
2172
- for (const line of psOutput.split('\n').slice(1)) { // skip header
2173
- const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
2174
- if (match) {
2175
- processes.set(match[1], { ppid: match[2], command: match[3] });
2176
- }
2177
- }
2178
- // Walk the tree: find all descendants of panePid
2179
- const descendants = [];
2180
- const queue = [panePid];
2181
- while (queue.length > 0) {
2182
- const parentPid = queue.shift();
2183
- for (const [pid, info] of processes) {
2184
- if (info.ppid === parentPid && pid !== panePid) {
2185
- descendants.push({ pid, command: info.command });
2186
- queue.push(pid);
2187
- }
2188
- }
2189
- }
2190
- // Filter out baseline processes
2191
- const activeProcesses = descendants.filter(p => {
2192
- return !BASELINE_PROCESS_PATTERNS.some(pattern => pattern.test(p.command));
2193
- });
2194
- // The Claude Code node process itself is always running — that's the main process.
2195
- // We care about processes BEYOND Claude itself and its baseline children.
2196
- // Claude's main process is the direct child of the pane PID.
2197
- // Filter it out: it's typically `node` or `claude` running the main Claude binary.
2198
- const nonClaude = activeProcesses.filter(p => {
2199
- const proc = processes.get(p.pid);
2200
- // Direct child of pane PID running claude/node is the main process
2201
- if (proc?.ppid === panePid) {
2202
- return !/\bclaude\b/.test(p.command) && !/\bnode\b.*\bclaude\b/.test(p.command);
2203
- }
2380
+ const psOutput = withSyncOp(() => execFileSync('ps', ['-eo', 'pid,ppid,command'], { encoding: 'utf-8', timeout: 5000 }));
2381
+ return this.computeHasActiveProcesses(panePid, psOutput);
2382
+ }
2383
+ catch {
2384
+ // If we can't check processes, assume active (fail-safe: don't kill)
2385
+ return true;
2386
+ }
2387
+ }
2388
+ /**
2389
+ * Async twin of hasActiveProcesses (§A) — the tmux list-panes goes through the
2390
+ * bounded wrapper and the non-tmux `ps` fork runs on `execFileAsync` (so the
2391
+ * whole probe is off the event loop). The fail-safe is preserved EXACTLY: an
2392
+ * indeterminate list-panes, an unparseable pane PID, OR any failure resolves
2393
+ * to TRUE (assume active — never kill on an unprobeable process tree). The
2394
+ * pure tree-walk is the SAME `computeHasActiveProcesses` the sync path uses.
2395
+ */
2396
+ async hasActiveProcessesAsync(tmuxSession) {
2397
+ try {
2398
+ const panes = await this.tmuxExecCoalesced('list-panes', tmuxSession, [
2399
+ 'list-panes',
2400
+ '-t',
2401
+ `=${tmuxSession}:`,
2402
+ '-F',
2403
+ '#{pane_pid}',
2404
+ ]);
2405
+ // Indeterminate (slow/timed-out tmux) fail-safe TRUE (don't kill). A
2406
+ // definitive-absent / parse failure of the pane PID is "no pane PID" ⇒
2407
+ // false, matching the sync version's `return false` on a bad PID.
2408
+ if (panes.state === 'indeterminate')
2204
2409
  return true;
2410
+ if (panes.state === 'definitely-absent')
2411
+ return false;
2412
+ const panePid = panes.stdout.trim();
2413
+ if (!panePid || !/^\d+$/.test(panePid))
2414
+ return false;
2415
+ const { stdout: psOutput } = await execFileAsync('ps', ['-eo', 'pid,ppid,command'], {
2416
+ timeout: 5000,
2417
+ killSignal: 'SIGKILL',
2205
2418
  });
2206
- return nonClaude.length > 0;
2419
+ return this.computeHasActiveProcesses(panePid, psOutput);
2207
2420
  }
2208
2421
  catch {
2209
2422
  // If we can't check processes, assume active (fail-safe: don't kill)
2210
2423
  return true;
2211
2424
  }
2212
2425
  }
2426
+ // ── (§A) hot-path dispatchers: monitorTick awaits these. When
2427
+ // `tmuxAsyncEnabled` they run the bounded async twins (off the event loop);
2428
+ // when OFF they call the EXACT sync method, so the off-path is byte-for-byte
2429
+ // today's behavior (the await on an already-resolved value is a microtask, no
2430
+ // semantic change). Kept tiny so the conversion at each monitorTick callsite
2431
+ // is a one-line substitution. ────────────────────────────────────────────
2432
+ async captureOutputMaybeAsync(tmuxSession, lines) {
2433
+ return this.tmuxAsyncEnabled
2434
+ ? this.captureOutputAsync(tmuxSession, lines)
2435
+ : this.captureOutput(tmuxSession, lines);
2436
+ }
2437
+ async captureMeaningfulTailMaybeAsync(tmuxSession, lines) {
2438
+ return this.tmuxAsyncEnabled
2439
+ ? this.captureMeaningfulTailAsync(tmuxSession, lines)
2440
+ : this.captureMeaningfulTail(tmuxSession, lines);
2441
+ }
2442
+ async hasActiveProcessesMaybeAsync(tmuxSession) {
2443
+ return this.tmuxAsyncEnabled
2444
+ ? this.hasActiveProcessesAsync(tmuxSession)
2445
+ : this.hasActiveProcesses(tmuxSession);
2446
+ }
2447
+ async detectCompletionMaybeAsync(tmuxSession) {
2448
+ return this.tmuxAsyncEnabled
2449
+ ? this.detectCompletionAsync(tmuxSession)
2450
+ : this.detectCompletion(tmuxSession);
2451
+ }
2452
+ async detectSessionCompletionMaybeAsync(session) {
2453
+ return this.tmuxAsyncEnabled
2454
+ ? this.detectSessionCompletionAsync(session)
2455
+ : this.detectSessionCompletion(session);
2456
+ }
2457
+ async recordBuildContextMaybeAsync(tmuxSession) {
2458
+ return this.tmuxAsyncEnabled
2459
+ ? this.recordBuildContextAsync(tmuxSession)
2460
+ : void this.recordBuildContext(tmuxSession);
2461
+ }
2462
+ async sendKeyMaybeAsync(tmuxSession, key) {
2463
+ return this.tmuxAsyncEnabled
2464
+ ? this.sendKeyAsync(tmuxSession, key)
2465
+ : this.sendKey(tmuxSession, key);
2466
+ }
2213
2467
  /**
2214
2468
  * Accumulated CPU-seconds of a session's non-baseline descendant processes.
2215
2469
  *
@@ -2227,10 +2481,10 @@ rm() { "${shimRunner}" rm "$@"; }
2227
2481
  */
2228
2482
  descendantCpuSeconds(tmuxSession) {
2229
2483
  try {
2230
- const panePid = execFileSync(this.config.tmuxPath, ['list-panes', '-t', `=${tmuxSession}:`, '-F', '#{pane_pid}'], { encoding: 'utf-8', timeout: 5000 }).trim();
2484
+ const panePid = withSyncOp(() => execFileSync(this.config.tmuxPath, ['list-panes', '-t', `=${tmuxSession}:`, '-F', '#{pane_pid}'], { encoding: 'utf-8', timeout: 5000 })).trim();
2231
2485
  if (!panePid || !/^\d+$/.test(panePid))
2232
2486
  return 0;
2233
- const psOutput = execFileSync('ps', ['-eo', 'pid,ppid,time,command'], { encoding: 'utf-8', timeout: 5000 });
2487
+ const psOutput = withSyncOp(() => execFileSync('ps', ['-eo', 'pid,ppid,time,command'], { encoding: 'utf-8', timeout: 5000 }));
2234
2488
  const procs = new Map();
2235
2489
  for (const line of psOutput.split('\n').slice(1)) {
2236
2490
  const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
@@ -2280,7 +2534,7 @@ rm() { "${shimRunner}" rm "$@"; }
2280
2534
  }
2281
2535
  for (const s of running) {
2282
2536
  try {
2283
- const panePid = execFileSync(this.config.tmuxPath, ['list-panes', '-t', `=${s.tmuxSession}:`, '-F', '#{pane_pid}'], { encoding: 'utf-8', timeout: 5000 }).trim();
2537
+ const panePid = withSyncOp(() => execFileSync(this.config.tmuxPath, ['list-panes', '-t', `=${s.tmuxSession}:`, '-F', '#{pane_pid}'], { encoding: 'utf-8', timeout: 5000 })).trim();
2284
2538
  if (panePid && /^\d+$/.test(panePid)) {
2285
2539
  out.push({ id: s.id, pid: Number(panePid) });
2286
2540
  }
@@ -2338,7 +2592,7 @@ rm() { "${shimRunner}" rm "$@"; }
2338
2592
  // raw bytes returned here are consumed by callers (sentinels, watchdog)
2339
2593
  // that own the actual state-detection patterns and their canary/registry
2340
2594
  // coverage. This method is the transport layer.
2341
- return execFileSync(this.config.tmuxPath, ['capture-pane', '-t', `=${tmuxSession}:`, '-p', '-S', `-${lines}`], { encoding: 'utf-8', timeout: 5000 });
2595
+ return withSyncOp(() => execFileSync(this.config.tmuxPath, ['capture-pane', '-t', `=${tmuxSession}:`, '-p', '-S', `-${lines}`], { encoding: 'utf-8', timeout: 5000 }));
2342
2596
  }
2343
2597
  catch {
2344
2598
  // @silent-fallback-ok — capture output, null handled by caller
@@ -2363,6 +2617,30 @@ rm() { "${shimRunner}" rm "$@"; }
2363
2617
  return null;
2364
2618
  return meaningfulTail(raw, lines);
2365
2619
  }
2620
+ /**
2621
+ * Async twin of captureOutput (§A hot path) — bounded, SIGKILL-capped
2622
+ * capture-pane through the single-flight wrapper. Any non-success outcome
2623
+ * (indeterminate/absent/timeout) resolves to `null`, exactly like the sync
2624
+ * version's catch — the caller's null handling is byte-for-byte preserved.
2625
+ */
2626
+ async captureOutputAsync(tmuxSession, lines = 100) {
2627
+ const out = await this.tmuxExecCoalesced('capture-pane', tmuxSession, [
2628
+ 'capture-pane',
2629
+ '-t',
2630
+ `=${tmuxSession}:`,
2631
+ '-p',
2632
+ '-S',
2633
+ `-${lines}`,
2634
+ ]);
2635
+ return out.state === 'success' ? out.stdout : null;
2636
+ }
2637
+ /** Async twin of captureMeaningfulTail — async capture, SAME pure trim/tail. */
2638
+ async captureMeaningfulTailAsync(tmuxSession, lines) {
2639
+ const raw = await this.captureOutputAsync(tmuxSession, Math.max(lines * 4, 50));
2640
+ if (raw === null)
2641
+ return null;
2642
+ return meaningfulTail(raw, lines);
2643
+ }
2366
2644
  /**
2367
2645
  * Transcript-activity probe — ground-truth liveness independent of the tmux
2368
2646
  * pane and process tree. A session doing MCP/tool work (e.g. driving Playwright)
@@ -2400,7 +2678,7 @@ rm() { "${shimRunner}" rm "$@"; }
2400
2678
  }
2401
2679
  currentPaneCwd(tmuxSession) {
2402
2680
  try {
2403
- const out = execFileSync(this.config.tmuxPath, ['display-message', '-p', '-t', `=${tmuxSession}:`, '#{pane_current_path}'], { encoding: 'utf-8', timeout: 2000 }).trim();
2681
+ const out = withSyncOp(() => execFileSync(this.config.tmuxPath, ['display-message', '-p', '-t', `=${tmuxSession}:`, '#{pane_current_path}'], { encoding: 'utf-8', timeout: 2000 })).trim();
2404
2682
  return out || null;
2405
2683
  }
2406
2684
  catch {
@@ -2408,6 +2686,20 @@ rm() { "${shimRunner}" rm "$@"; }
2408
2686
  return null;
2409
2687
  }
2410
2688
  }
2689
+ /**
2690
+ * Async twin of currentPaneCwd (§A) — preserves the LIGHTER 2000ms budget the
2691
+ * sync version uses (cwd tracking is best-effort enrichment, not a liveness
2692
+ * probe). A distinct op key (`display-message-cwd`) keeps single-flight from
2693
+ * coalescing this `pane_current_path` probe with the liveness
2694
+ * `pane_current_command` probe of the same session. Non-success ⇒ null.
2695
+ */
2696
+ async currentPaneCwdAsync(tmuxSession) {
2697
+ const out = await this.tmuxExecCoalesced('display-message-cwd', tmuxSession, ['display-message', '-p', '-t', `=${tmuxSession}:`, '#{pane_current_path}'], { timeoutMs: 2000 });
2698
+ if (out.state !== 'success')
2699
+ return null;
2700
+ const trimmed = out.stdout.trim();
2701
+ return trimmed || null;
2702
+ }
2411
2703
  recordBuildContext(tmuxSession) {
2412
2704
  if (!this.buildContextStore)
2413
2705
  return;
@@ -2421,6 +2713,20 @@ rm() { "${shimRunner}" rm "$@"; }
2421
2713
  console.warn(`[SessionManager] Failed to record build context for "${tmuxSession}": ${err instanceof Error ? err.message : String(err)}`);
2422
2714
  }
2423
2715
  }
2716
+ /** Async twin of recordBuildContext — async cwd probe, SAME persistence. */
2717
+ async recordBuildContextAsync(tmuxSession) {
2718
+ if (!this.buildContextStore)
2719
+ return;
2720
+ const currentCwd = await this.currentPaneCwdAsync(tmuxSession);
2721
+ if (!currentCwd)
2722
+ return;
2723
+ try {
2724
+ this.buildContextStore.record(tmuxSession, this.config.projectDir, currentCwd);
2725
+ }
2726
+ catch (err) {
2727
+ console.warn(`[SessionManager] Failed to record build context for "${tmuxSession}": ${err instanceof Error ? err.message : String(err)}`);
2728
+ }
2729
+ }
2424
2730
  withBuildContextRestoreNote(tmuxSession, initialMessage, resumeSessionId) {
2425
2731
  if (!resumeSessionId || !this.buildContextStore)
2426
2732
  return initialMessage;
@@ -2456,7 +2762,7 @@ rm() { "${shimRunner}" rm "$@"; }
2456
2762
  }
2457
2763
  let resolved = null;
2458
2764
  try {
2459
- const out = execFileSync(this.config.tmuxPath, ['show-environment', '-t', `=${tmuxSession}`, 'INSTAR_FRAMEWORK'], { encoding: 'utf-8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
2765
+ const out = withSyncOp(() => execFileSync(this.config.tmuxPath, ['show-environment', '-t', `=${tmuxSession}`, 'INSTAR_FRAMEWORK'], { encoding: 'utf-8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] })).trim();
2460
2766
  const value = out.startsWith('INSTAR_FRAMEWORK=') ? out.slice('INSTAR_FRAMEWORK='.length) : '';
2461
2767
  if (value === 'claude-code' || value === 'codex-cli') {
2462
2768
  resolved = value;
@@ -2479,8 +2785,8 @@ rm() { "${shimRunner}" rm "$@"; }
2479
2785
  // Send text literally, then Enter separately. `--` terminates option
2480
2786
  // parsing so input can never be interpreted as a send-keys flag
2481
2787
  // (FABLE-MODEL-ESCALATION-SPEC §5.3 hardening; safe for all callers).
2482
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, '-l', '--', input], { encoding: 'utf-8', timeout: 5000 });
2483
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, 'Enter'], { encoding: 'utf-8', timeout: 5000 });
2788
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, '-l', '--', input], { encoding: 'utf-8', timeout: 5000 }));
2789
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, 'Enter'], { encoding: 'utf-8', timeout: 5000 }));
2484
2790
  return true;
2485
2791
  }
2486
2792
  catch {
@@ -2495,7 +2801,7 @@ rm() { "${shimRunner}" rm "$@"; }
2495
2801
  */
2496
2802
  sendKey(tmuxSession, key) {
2497
2803
  try {
2498
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, key], { encoding: 'utf-8', timeout: 5000 });
2804
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, key], { encoding: 'utf-8', timeout: 5000 }));
2499
2805
  return true;
2500
2806
  }
2501
2807
  catch {
@@ -2503,6 +2809,26 @@ rm() { "${shimRunner}" rm "$@"; }
2503
2809
  return false;
2504
2810
  }
2505
2811
  }
2812
+ /**
2813
+ * Async twin of sendKey (§A) — bounded, SIGKILL-capped send-keys. This is a
2814
+ * WRITE, so it is NOT single-flight-coalesced (two Enter presses must each
2815
+ * fire — coalescing would silently swallow one); it still respects the
2816
+ * max-in-flight ceiling (fail CLOSED ⇒ report not-sent) and feeds (C). Used by
2817
+ * the paste-retry path only — NOT the injection send-keys sequence (which
2818
+ * stays synchronous because its timing IS the correctness mechanism).
2819
+ */
2820
+ async sendKeyAsync(tmuxSession, key) {
2821
+ if (this.tmuxInflightCount >= this.tmuxMaxInFlight)
2822
+ return false;
2823
+ this.tmuxInflightCount += 1;
2824
+ try {
2825
+ const out = await this.tmuxExecAsync(['send-keys', '-t', `=${tmuxSession}:`, key]);
2826
+ return out.state === 'success';
2827
+ }
2828
+ finally {
2829
+ this.tmuxInflightCount = Math.max(0, this.tmuxInflightCount - 1);
2830
+ }
2831
+ }
2506
2832
  /**
2507
2833
  * List all sessions that are currently running.
2508
2834
  * Pure filter — does not mutate state. The monitor tick handles lifecycle transitions.
@@ -2689,14 +3015,27 @@ rm() { "${shimRunner}" rm "$@"; }
2689
3015
  suggestions,
2690
3016
  };
2691
3017
  }
3018
+ /**
3019
+ * Pure completion-pattern scan — shared by the sync detect methods and their
3020
+ * async twins so the `.includes()` matching can NEVER drift between paths.
3021
+ * `output === null` (an unreadable capture) is NOT a completion.
3022
+ */
3023
+ matchesAnyPattern(output, patterns) {
3024
+ if (!output)
3025
+ return false;
3026
+ return patterns.some((pattern) => output.includes(pattern));
3027
+ }
2692
3028
  /**
2693
3029
  * Detect if a session has completed by checking output patterns.
2694
3030
  */
2695
3031
  detectCompletion(tmuxSession) {
2696
3032
  const output = this.captureOutput(tmuxSession, 30);
2697
- if (!output)
2698
- return false;
2699
- return this.config.completionPatterns.some(pattern => output.includes(pattern));
3033
+ return this.matchesAnyPattern(output, this.config.completionPatterns);
3034
+ }
3035
+ /** Async twin of detectCompletion — bounded capture, identical pattern scan. */
3036
+ async detectCompletionAsync(tmuxSession) {
3037
+ const output = await this.captureOutputAsync(tmuxSession, 30);
3038
+ return this.matchesAnyPattern(output, this.config.completionPatterns);
2700
3039
  }
2701
3040
  /**
2702
3041
  * Per-session completion detection (june15-headless-spawn-reroute, PR2).
@@ -2713,9 +3052,15 @@ rm() { "${shimRunner}" rm "$@"; }
2713
3052
  if (!patterns || patterns.length === 0)
2714
3053
  return false;
2715
3054
  const output = this.captureOutput(session.tmuxSession, 30);
2716
- if (!output)
3055
+ return this.matchesAnyPattern(output, patterns);
3056
+ }
3057
+ /** Async twin of detectSessionCompletion — bounded capture, identical scan. */
3058
+ async detectSessionCompletionAsync(session) {
3059
+ const patterns = session.completionPatterns;
3060
+ if (!patterns || patterns.length === 0)
2717
3061
  return false;
2718
- return patterns.some((pattern) => output.includes(pattern));
3062
+ const output = await this.captureOutputAsync(session.tmuxSession, 30);
3063
+ return this.matchesAnyPattern(output, patterns);
2719
3064
  }
2720
3065
  /**
2721
3066
  * Reap completed/zombie sessions.
@@ -2734,9 +3079,9 @@ rm() { "${shimRunner}" rm "$@"; }
2734
3079
  // Kill the tmux session if it's still hanging around
2735
3080
  if (this.isSessionAlive(session.tmuxSession)) {
2736
3081
  try {
2737
- execFileSync(this.config.tmuxPath, ['kill-session', '-t', `=${session.tmuxSession}`], {
3082
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['kill-session', '-t', `=${session.tmuxSession}`], {
2738
3083
  encoding: 'utf-8',
2739
- });
3084
+ }));
2740
3085
  }
2741
3086
  catch { /* ignore */ }
2742
3087
  }
@@ -3022,12 +3367,12 @@ rm() { "${shimRunner}" rm "$@"; }
3022
3367
  else {
3023
3368
  console.log(`[SessionManager] Spawning interactive session "${tmuxSession}" (framework: ${framework})`);
3024
3369
  }
3025
- execFileSync(this.config.tmuxPath, tmuxArgs, { encoding: 'utf-8' });
3370
+ withSyncOp(() => execFileSync(this.config.tmuxPath, tmuxArgs, { encoding: 'utf-8' }));
3026
3371
  // Increase tmux scrollback buffer for dashboard history support
3027
3372
  try {
3028
- execFileSync(this.config.tmuxPath, [
3373
+ withSyncOp(() => execFileSync(this.config.tmuxPath, [
3029
3374
  'set-option', '-t', `=${tmuxSession}:`, 'history-limit', '50000',
3030
- ], { encoding: 'utf-8', timeout: 5000 });
3375
+ ], { encoding: 'utf-8', timeout: 5000 }));
3031
3376
  }
3032
3377
  catch {
3033
3378
  // @silent-fallback-ok — history-limit is a nice-to-have
@@ -3262,7 +3607,7 @@ rm() { "${shimRunner}" rm "$@"; }
3262
3607
  // Kill existing triage session if present (triage sessions are ephemeral)
3263
3608
  if (this.tmuxSessionExists(tmuxSession)) {
3264
3609
  try {
3265
- execFileSync(this.config.tmuxPath, ['kill-session', '-t', tmuxSession], { encoding: 'utf-8' });
3610
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['kill-session', '-t', tmuxSession], { encoding: 'utf-8' }));
3266
3611
  }
3267
3612
  catch {
3268
3613
  // Best-effort
@@ -3297,12 +3642,12 @@ rm() { "${shimRunner}" rm "$@"; }
3297
3642
  tmuxArgs.push('--resume', options.resumeSessionId);
3298
3643
  console.log(`[SessionManager] Resuming triage session: ${options.resumeSessionId}`);
3299
3644
  }
3300
- execFileSync(this.config.tmuxPath, tmuxArgs, { encoding: 'utf-8' });
3645
+ withSyncOp(() => execFileSync(this.config.tmuxPath, tmuxArgs, { encoding: 'utf-8' }));
3301
3646
  // Increase tmux scrollback buffer for dashboard history support
3302
3647
  try {
3303
- execFileSync(this.config.tmuxPath, [
3648
+ withSyncOp(() => execFileSync(this.config.tmuxPath, [
3304
3649
  'set-option', '-t', `=${tmuxSession}:`, 'history-limit', '50000',
3305
- ], { encoding: 'utf-8', timeout: 5000 });
3650
+ ], { encoding: 'utf-8', timeout: 5000 }));
3306
3651
  }
3307
3652
  catch {
3308
3653
  // @silent-fallback-ok — history-limit is a nice-to-have
@@ -3728,28 +4073,33 @@ rm() { "${shimRunner}" rm "$@"; }
3728
4073
  const maxAttempts = 2;
3729
4074
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3730
4075
  try {
4076
+ // §B: the injection send-keys + /bin/sleep stay SYNCHRONOUS — their
4077
+ // synchronous timing IS the correctness mechanism (D1) and so are EXCLUDED
4078
+ // from §A async conversion — but every blocking call funnels through
4079
+ // withSyncOp so a sleep/wake drift across this sequence reads as an
4080
+ // event-loop BLOCK, not sleep.
3731
4081
  if (text.includes('\n')) {
3732
4082
  // Multi-line: use bracketed paste mode.
3733
4083
  // The terminal (and Claude Code's readline) treats everything between
3734
4084
  // \e[200~ and \e[201~ as a single paste — newlines are literal, not Enter.
3735
4085
  // This completely avoids load-buffer/paste-buffer and their TCC prompts.
3736
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '\x1b[200~'], {
4086
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '\x1b[200~'], {
3737
4087
  encoding: 'utf-8', timeout: 5000,
3738
- });
3739
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '-l', text], {
4088
+ }));
4089
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '-l', text], {
3740
4090
  encoding: 'utf-8', timeout: 10000,
3741
- });
3742
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '\x1b[201~'], {
4091
+ }));
4092
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '\x1b[201~'], {
3743
4093
  encoding: 'utf-8', timeout: 5000,
3744
- });
3745
- execFileSync('/bin/sleep', [postPasteDelaySec], { timeout: 4000 });
4094
+ }));
4095
+ withSyncOp(() => execFileSync('/bin/sleep', [postPasteDelaySec], { timeout: 4000 }));
3746
4096
  for (let i = 0; i < enterPresses; i++) {
3747
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
4097
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
3748
4098
  encoding: 'utf-8', timeout: 5000,
3749
- });
4099
+ }));
3750
4100
  if (i < enterPresses - 1) {
3751
4101
  try {
3752
- execFileSync('/bin/sleep', ['0.3'], { timeout: 2000 });
4102
+ withSyncOp(() => execFileSync('/bin/sleep', ['0.3'], { timeout: 2000 }));
3753
4103
  }
3754
4104
  catch { /* ignore */ }
3755
4105
  }
@@ -3757,16 +4107,16 @@ rm() { "${shimRunner}" rm "$@"; }
3757
4107
  }
3758
4108
  else {
3759
4109
  // Single-line: simple send-keys
3760
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '-l', text], {
4110
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '-l', text], {
3761
4111
  encoding: 'utf-8', timeout: 10000,
3762
- });
4112
+ }));
3763
4113
  for (let i = 0; i < enterPresses; i++) {
3764
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
4114
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
3765
4115
  encoding: 'utf-8', timeout: 5000,
3766
- });
4116
+ }));
3767
4117
  if (i < enterPresses - 1) {
3768
4118
  try {
3769
- execFileSync('/bin/sleep', ['0.3'], { timeout: 2000 });
4119
+ withSyncOp(() => execFileSync('/bin/sleep', ['0.3'], { timeout: 2000 }));
3770
4120
  }
3771
4121
  catch { /* ignore */ }
3772
4122
  }
@@ -3799,7 +4149,7 @@ rm() { "${shimRunner}" rm "$@"; }
3799
4149
  // through multiple callers. The 300ms pause is brief and only hits on failure
3800
4150
  // (max once per injection), so the event loop impact is negligible in practice.
3801
4151
  try {
3802
- execFileSync('/bin/sleep', ['0.3'], { timeout: 2000 });
4152
+ withSyncOp(() => execFileSync('/bin/sleep', ['0.3'], { timeout: 2000 }));
3803
4153
  }
3804
4154
  catch { /* ignore */ }
3805
4155
  continue;
@@ -4004,21 +4354,23 @@ rm() { "${shimRunner}" rm "$@"; }
4004
4354
  const target = `=${tmuxSession}:`;
4005
4355
  const tmuxPath = this.config.tmuxPath;
4006
4356
  try {
4357
+ // §B: the stuck-input recovery send-keys stay sync (escalating timing IS the
4358
+ // mechanism) but funnel through withSyncOp for the marker.
4007
4359
  if (attempt === 0 || attempt === 1) {
4008
- execFileSync(tmuxPath, ['send-keys', '-t', target, 'Enter'], { encoding: 'utf-8', timeout: 5000 });
4360
+ withSyncOp(() => execFileSync(tmuxPath, ['send-keys', '-t', target, 'Enter'], { encoding: 'utf-8', timeout: 5000 }));
4009
4361
  }
4010
4362
  else if (attempt === 2) {
4011
4363
  // Escalate: literal carriage-return — bypasses any Enter-specific consumer.
4012
- execFileSync(tmuxPath, ['send-keys', '-t', target, 'C-m'], { encoding: 'utf-8', timeout: 5000 });
4364
+ withSyncOp(() => execFileSync(tmuxPath, ['send-keys', '-t', target, 'C-m'], { encoding: 'utf-8', timeout: 5000 }));
4013
4365
  }
4014
4366
  else {
4015
4367
  // Final attempt: Enter, brief sleep, Enter — covers sub-second consume races.
4016
- execFileSync(tmuxPath, ['send-keys', '-t', target, 'Enter'], { encoding: 'utf-8', timeout: 5000 });
4368
+ withSyncOp(() => execFileSync(tmuxPath, ['send-keys', '-t', target, 'Enter'], { encoding: 'utf-8', timeout: 5000 }));
4017
4369
  try {
4018
- execFileSync('/bin/sleep', ['0.15'], { timeout: 1000 });
4370
+ withSyncOp(() => execFileSync('/bin/sleep', ['0.15'], { timeout: 1000 }));
4019
4371
  }
4020
4372
  catch { /* @silent-fallback-ok — sleep is best-effort */ }
4021
- execFileSync(tmuxPath, ['send-keys', '-t', target, 'Enter'], { encoding: 'utf-8', timeout: 5000 });
4373
+ withSyncOp(() => execFileSync(tmuxPath, ['send-keys', '-t', target, 'Enter'], { encoding: 'utf-8', timeout: 5000 }));
4022
4374
  }
4023
4375
  }
4024
4376
  catch (err) {
@@ -4073,12 +4425,12 @@ rm() { "${shimRunner}" rm "$@"; }
4073
4425
  console.log(`[SessionManager] Consent dialog detected in "${tmuxSession}" — auto-accepting`);
4074
4426
  try {
4075
4427
  // Press Down to select "Yes, I accept", then Enter to confirm
4076
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, 'Down'], {
4428
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, 'Down'], {
4077
4429
  encoding: 'utf-8', timeout: 5000,
4078
- });
4079
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, 'Enter'], {
4430
+ }));
4431
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['send-keys', '-t', `=${tmuxSession}:`, 'Enter'], {
4080
4432
  encoding: 'utf-8', timeout: 5000,
4081
- });
4433
+ }));
4082
4434
  }
4083
4435
  catch {
4084
4436
  // Best-effort — if this fails, the session will be stuck but not crashed
@@ -4143,11 +4495,13 @@ rm() { "${shimRunner}" rm "$@"; }
4143
4495
  }
4144
4496
  tmuxSessionExists(name) {
4145
4497
  try {
4146
- execFileSync(this.config.tmuxPath, ['has-session', '-t', `=${name}`], {
4498
+ // §B: request-route existence check stays sync (§A excludes it) but funnels
4499
+ // through withSyncOp for the in-flight marker.
4500
+ withSyncOp(() => execFileSync(this.config.tmuxPath, ['has-session', '-t', `=${name}`], {
4147
4501
  encoding: 'utf-8',
4148
4502
  stdio: ['pipe', 'pipe', 'pipe'],
4149
4503
  timeout: 5000,
4150
- });
4504
+ }));
4151
4505
  return true;
4152
4506
  }
4153
4507
  catch {