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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +160 -6
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +29 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/InFlightSyncOpMarker.d.ts +29 -0
- package/dist/core/InFlightSyncOpMarker.d.ts.map +1 -0
- package/dist/core/InFlightSyncOpMarker.js +149 -0
- package/dist/core/InFlightSyncOpMarker.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts +16 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +91 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +146 -1
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +498 -144
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SleepWakeDetector.d.ts +33 -0
- package/dist/core/SleepWakeDetector.d.ts.map +1 -1
- package/dist/core/SleepWakeDetector.js +53 -0
- package/dist/core/SleepWakeDetector.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +23 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +52 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/lifeline/ServerSupervisor.d.ts +42 -0
- package/dist/lifeline/ServerSupervisor.d.ts.map +1 -1
- package/dist/lifeline/ServerSupervisor.js +79 -4
- package/dist/lifeline/ServerSupervisor.js.map +1 -1
- package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
- package/dist/lifeline/TelegramLifeline.js +8 -0
- package/dist/lifeline/TelegramLifeline.js.map +1 -1
- package/dist/monitoring/DegradedTmuxGuard.d.ts +161 -0
- package/dist/monitoring/DegradedTmuxGuard.d.ts.map +1 -0
- package/dist/monitoring/DegradedTmuxGuard.js +277 -0
- package/dist/monitoring/DegradedTmuxGuard.js.map +1 -0
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +18 -0
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +13 -5
- package/dist/server/routes.js.map +1 -1
- package/package.json +2 -2
- package/scripts/lint-no-direct-destructive.js +14 -0
- package/scripts/lint-sync-subprocess-chokepoint.js +208 -0
- package/scripts/sync-subprocess-chokepoint-baseline.json +148 -0
- package/src/data/builtin-manifest.json +65 -65
- package/upgrades/1.3.642.md +46 -0
- package/upgrades/1.3.643.md +66 -0
- package/upgrades/side-effects/false-excuse-deferral-stop-guard.md +75 -0
- 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
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
this.state.saveSession(
|
|
1001
|
-
this.emit('sessionComplete',
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
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
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
2197
|
-
|
|
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
|
|
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
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|