baxian 1.2.56 → 1.2.57

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.
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { join, normalize } from 'node:path';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { createSignalToken } from './phase-signal.js';
@@ -13,7 +14,7 @@ import { findForeignTaskTip } from './lineage.js';
13
14
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
14
15
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
15
16
  import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
16
- import { RepoStore, createRepoStoreCache } from './repo-store.js';
17
+ import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
17
18
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
18
19
  import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
19
20
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
@@ -85,6 +86,10 @@ export function buildLaunchCommand(agent) {
85
86
  }
86
87
  return segments.join(' ');
87
88
  }
89
+ // cd re-resolves by pathname: a reused pane shell may sit on a deleted-and-recreated cwd inode.
90
+ export function launchCommandIn(dir, agent) {
91
+ return `cd ${shellQuote(dir)} && ${buildLaunchCommand(agent)}`;
92
+ }
88
93
  function agentRuntimeKindFor(agent) {
89
94
  return agent.runtime;
90
95
  }
@@ -449,8 +454,11 @@ export class AgentManager {
449
454
  `if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
450
455
  }
451
456
  if (mode === 'create' && alive) {
452
- throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
453
- `it creates itself — kill it manually or pick a different agent id`);
457
+ const reclaimed = await this.runUnderSessionLifecycle(agentId, () => this.reclaimHalfCreatedSession(tmux, agentId));
458
+ if (!reclaimed) {
459
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
460
+ `it creates itself — kill it manually or pick a different agent id`);
461
+ }
454
462
  }
455
463
  if (alive && (mode === 'runtime' || mode === 'recover')) {
456
464
  return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
@@ -470,9 +478,78 @@ export class AgentManager {
470
478
  // before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
471
479
  await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
472
480
  await this.ensureSkillDirSafe(runner, workdir, subdir);
473
- await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
481
+ await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot);
474
482
  });
475
483
  }
484
+ // A rollback only kills if no adoption bumped the generation since its create.
485
+ sessionLifecycleChain = new Map();
486
+ adoptGeneration = new Map();
487
+ runUnderSessionLifecycle(agentId, fn) {
488
+ const prev = this.sessionLifecycleChain.get(agentId) ?? Promise.resolve();
489
+ const cur = prev.then(fn, fn);
490
+ const settled = cur.then(() => undefined, () => undefined);
491
+ this.sessionLifecycleChain.set(agentId, settled);
492
+ void settled.finally(() => {
493
+ if (this.sessionLifecycleChain.get(agentId) === settled)
494
+ this.sessionLifecycleChain.delete(agentId);
495
+ });
496
+ return cur;
497
+ }
498
+ // Inside the lifecycle lock, a generation bump since create means handoff — stand down.
499
+ async rollbackCreatedSession(agentId, partial, reason, opts = {}) {
500
+ await this.runUnderSessionLifecycle(agentId, async () => {
501
+ try {
502
+ const ref = partial.sessionRef;
503
+ if (!ref) {
504
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: no session ref recorded`);
505
+ return;
506
+ }
507
+ if ((this.adoptGeneration.get(agentId) ?? 0) !== (partial.genAtCreate ?? 0)) {
508
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: session adopted since create — leaving it to its successor`);
509
+ return;
510
+ }
511
+ if (opts.expectCreationToken !== undefined) {
512
+ let fresh;
513
+ try {
514
+ fresh = await this.agentStore.get(agentId);
515
+ }
516
+ catch (storeErr) {
517
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent store read failed — session left in place:`, storeErr);
518
+ return;
519
+ }
520
+ if (!fresh) {
521
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent record gone`);
522
+ return;
523
+ }
524
+ if (fresh.creationToken !== opts.expectCreationToken) {
525
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: creationToken rotated — leaving the session to its successor`);
526
+ return;
527
+ }
528
+ }
529
+ const cfg = this.getAgentConfig(agentId);
530
+ if (!cfg) {
531
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent no longer in config`);
532
+ return;
533
+ }
534
+ const runner = this.createRunnerFor(cfg);
535
+ const outcome = await new TmuxManager(runner).killSessionRef(ref);
536
+ if (outcome === 'stale-server') {
537
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux server generation changed — leaving session ${ref.sessionId} untouched`);
538
+ }
539
+ else if (outcome === 'killed') {
540
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
541
+ }
542
+ }
543
+ catch (cleanupErr) {
544
+ console.warn(`[AgentManager] created-session rollback (${reason}) failed for ${agentId}:`, cleanupErr);
545
+ }
546
+ });
547
+ }
548
+ async rollbackEnsureSessionFailure(agentId, err, reason) {
549
+ if (!(err instanceof EnsureSessionError) || !err.partial.createdSession)
550
+ return;
551
+ await this.rollbackCreatedSession(agentId, err.partial, reason);
552
+ }
476
553
  skillDirChain = new Map();
477
554
  skillDirLockKey(agent, workdir) {
478
555
  const host = hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host));
@@ -491,19 +568,21 @@ export class AgentManager {
491
568
  });
492
569
  return run;
493
570
  }
494
- async atomicWriteFile(runner, finalPath, content) {
495
- const tmp = `${finalPath}.baxian-tmp`;
496
- await runner.writeFile(tmp, content);
497
- const mv = `mv -f ${shellQuote(tmp)} ${shellQuote(finalPath)}`;
498
- const res = await runner.exec(`sh -c ${shellQuote(mv)}`);
499
- if (res.exitCode !== 0) {
500
- throw new Error(`atomic skill write failed (${finalPath}): ${res.stderr || 'unknown error'}`);
571
+ async atomicWriteFile(runner, workdir, finalPath, content) {
572
+ const tmp = `${finalPath}.baxian-tmp-${randomBytes(6).toString('hex')}`;
573
+ try {
574
+ await stageFileGuarded(runner, workdir, tmp, content);
575
+ await moveFileIntoPlace(runner, tmp, finalPath, { guardRoot: workdir });
576
+ }
577
+ catch (err) {
578
+ throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
501
579
  }
502
580
  }
503
581
  async ensureSkillDirSafe(runner, workdir, subdir) {
504
582
  const top = subdir.split('/')[0];
505
583
  const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
506
- const inner = `cd ${shellQuote(workdir)} && ` +
584
+ const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
585
+ `cd ${shellQuote(workdir)} && ` +
507
586
  `for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
508
587
  `printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
509
588
  `exit 3; fi; done && ` +
@@ -516,10 +595,12 @@ export class AgentManager {
516
595
  throw new Error(`failed to prepare a symlink-safe ${subdir} in ${workdir}: ${res.stderr || 'unknown error'}`);
517
596
  }
518
597
  }
519
- async tagSessionSkillsVersion(tmux, agentId) {
598
+ async tagSessionSkillsVersion(tmux, agentId, ref) {
520
599
  if (this.skillRegistry.names().length === 0)
521
600
  return;
522
- await tmux.setOption(agentId, '@baxian-skills-version', this.skillRegistry.contentHash());
601
+ await this.setSessionOptions(tmux, agentId, ref, [
602
+ ['@baxian-skills-version', this.skillRegistry.contentHash()],
603
+ ]);
523
604
  }
524
605
  async replSkillsStale(tmux, agentId) {
525
606
  if (this.skillRegistry.names().length === 0)
@@ -528,7 +609,8 @@ export class AgentManager {
528
609
  return tagged !== this.skillRegistry.contentHash();
529
610
  }
530
611
  async excludeInjectedSkills(runner, workdir, subdir) {
531
- const inner = `cd ${shellQuote(workdir)} && ` +
612
+ const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
613
+ `cd ${shellQuote(workdir)} && ` +
532
614
  `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
533
615
  `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
534
616
  `if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
@@ -551,35 +633,46 @@ export class AgentManager {
551
633
  }
552
634
  throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
553
635
  }
554
- async pinRuntimeSessionOptions(tmux, agentId) {
555
- await tmux.setOption(agentId, 'prefix', 'C-b');
556
- await tmux.setOption(agentId, 'prefix2', 'None');
557
- await tmux.setOption(agentId, 'mouse', 'on');
636
+ runtimePinEntries() {
637
+ return [
638
+ ['prefix', 'C-b'],
639
+ ['prefix2', 'None'],
640
+ ['mouse', 'on'],
641
+ ];
558
642
  }
559
- async pinFreshSessionOptions(tmux, agentId) {
560
- await tmux.setOption(agentId, 'window-size', 'latest');
561
- await this.pinRuntimeSessionOptions(tmux, agentId);
643
+ // A name (or even a bare $id) can rebind to a same-name successor mid-flight;
644
+ // every option write goes through the identity-checked by-ref batch.
645
+ async setSessionOptions(tmux, agentId, ref, entries) {
646
+ const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
647
+ if (outcome === 'gone') {
648
+ throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
649
+ }
562
650
  }
563
651
  async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
564
652
  return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
565
653
  }
566
654
  async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
567
655
  let createdSession = false;
656
+ let createdRef;
657
+ const genAtCreate = this.adoptGeneration.get(agentId) ?? 0;
568
658
  const runtime = agentRuntimeKindFor(agent);
569
659
  try {
570
- await tmux.createSession(agentId, sessionDir);
660
+ createdRef = await tmux.createSession(agentId, sessionDir);
571
661
  createdSession = true;
572
- await tmux.setOption(agentId, '@baxian-agent-id', agentId);
573
- await tmux.setOption(agentId, '@baxian-runtime', agent.runtime);
574
- await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
575
- await tmux.setOption(agentId, 'allow-passthrough', 'on');
576
- await tmux.setOption(agentId, 'set-titles', 'on');
577
- await this.pinFreshSessionOptions(tmux, agentId);
578
- await tmux.setOption(agentId, 'status-right', '');
662
+ await this.setSessionOptions(tmux, agentId, createdRef, [
663
+ ['@baxian-agent-id', agentId],
664
+ ['@baxian-runtime', agent.runtime],
665
+ [WORKDIR_SESSION_OPTION, sessionDir],
666
+ ['allow-passthrough', 'on'],
667
+ ['set-titles', 'on'],
668
+ ['window-size', 'latest'],
669
+ ...this.runtimePinEntries(),
670
+ ['status-right', ''],
671
+ ]);
579
672
  await tmux.setServerOption('extended-keys', 'on');
580
673
  await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
581
- const paneId = await tmux.getSinglePaneId(agentId);
582
- await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(agent)}\n`);
674
+ const paneId = await tmux.getSinglePaneIdByRef(createdRef);
675
+ await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
583
676
  await tmux.handleTrustDialog(paneId, runtime, {
584
677
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
585
678
  });
@@ -587,11 +680,13 @@ export class AgentManager {
587
680
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
588
681
  scrollback: 0,
589
682
  });
590
- await this.tagSessionSkillsVersion(tmux, agentId);
591
- return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir };
683
+ await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
684
+ return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
592
685
  }
593
686
  catch (err) {
594
- const partial = { createdSession, agentId };
687
+ const partial = { createdSession, agentId, genAtCreate };
688
+ if (createdRef)
689
+ partial.sessionRef = createdRef;
595
690
  if (err instanceof ReplNotReadyError) {
596
691
  partial.lastScreen = err.lastScreen;
597
692
  if (createdSession && detectStartupDialog(err.lastScreen, runtime)) {
@@ -607,22 +702,12 @@ export class AgentManager {
607
702
  console.warn(`[bootstrap] ${agentId} not in config at bootstrap start — aborting`);
608
703
  return;
609
704
  }
610
- const tryKillOrphanSession = async (reason) => {
611
- try {
612
- const runner = this.createRunnerFor(cfgAtStart);
613
- await new TmuxManager(runner).killSession(agentId);
614
- }
615
- catch (cleanupErr) {
616
- console.warn(`[bootstrap] orphan killSession (${reason}) failed for ${agentId}:`, cleanupErr);
617
- }
618
- };
619
705
  try {
620
706
  const result = await this.ensureSession(agentId, 'create');
621
707
  if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.paneId))) {
622
708
  const current = await this.agentStore.get(agentId);
623
709
  if (current && current.creationToken !== creationToken) {
624
- console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — killing orphan session`);
625
- await tryKillOrphanSession('greeting-failure token mismatch');
710
+ console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
626
711
  return;
627
712
  }
628
713
  await this.markGreetingFailed(agentId, creationToken);
@@ -643,8 +728,7 @@ export class AgentManager {
643
728
  };
644
729
  });
645
730
  if (!resolvedExisting) {
646
- console.warn(`[bootstrap] ${agentId} creationToken mismatch on success — killing orphan session`);
647
- await tryKillOrphanSession('post-success token mismatch');
731
+ console.warn(`[bootstrap] ${agentId} creationToken mismatch on success — leaving the session to its successor`);
648
732
  return;
649
733
  }
650
734
  await this.safeEmit({
@@ -662,8 +746,7 @@ export class AgentManager {
662
746
  if (err.partial.createdSession) {
663
747
  const fresh = await this.agentStore.get(agentId);
664
748
  if (!fresh || fresh.creationToken !== creationToken) {
665
- console.warn(`[bootstrap] ${agentId} dialog-path token mismatch — killing orphan session`);
666
- await tryKillOrphanSession('dialog-path token mismatch');
749
+ console.warn(`[bootstrap] ${agentId} dialog-path token mismatch — leaving the session to its successor`);
667
750
  return;
668
751
  }
669
752
  }
@@ -674,7 +757,9 @@ export class AgentManager {
674
757
  return;
675
758
  }
676
759
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
677
- await tryKillOrphanSession('hard-failure rollback');
760
+ await this.rollbackCreatedSession(agentId, err.partial, 'hard-failure rollback', {
761
+ expectCreationToken: creationToken,
762
+ });
678
763
  }
679
764
  const message = err instanceof Error ? err.message : String(err);
680
765
  await this.markBootstrapFailed(agentId, creationToken, message);
@@ -1143,27 +1228,39 @@ export class AgentManager {
1143
1228
  signal.addEventListener('abort', onAbort, { once: true });
1144
1229
  });
1145
1230
  }
1231
+ // Claim snapshot and adoption declaration share one critical section with rollbacks.
1146
1232
  async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
1147
- let claim;
1233
+ return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
1234
+ }
1235
+ async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
1236
+ let snapshot;
1148
1237
  try {
1149
- claim = await tmux.getOption(agentId, '@baxian-agent-id');
1238
+ snapshot = await tmux.getSessionSnapshot(agentId);
1150
1239
  }
1151
1240
  catch (err) {
1152
- throw new EnsureSessionError({ createdSession: false, agentId }, `getOption(@baxian-agent-id) failed: ${err instanceof Error ? err.message : String(err)}`);
1241
+ throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1153
1242
  }
1154
- if (claim !== agentId) {
1243
+ if (!snapshot) {
1244
+ return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1245
+ }
1246
+ const sessionRef = snapshot.ref;
1247
+ if (snapshot.claim !== agentId) {
1248
+ if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
1249
+ return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1250
+ }
1155
1251
  throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
1156
- `(got "${claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
1252
+ `(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
1157
1253
  }
1254
+ this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
1158
1255
  try {
1159
- await this.pinRuntimeSessionOptions(tmux, agentId);
1256
+ await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
1160
1257
  }
1161
1258
  catch (err) {
1162
- throw new EnsureSessionError({ createdSession: false, agentId }, `pinRuntimeSessionOptions failed: ${err instanceof Error ? err.message : String(err)}`);
1259
+ throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
1163
1260
  }
1164
1261
  let paneId;
1165
1262
  try {
1166
- paneId = await tmux.getSinglePaneId(agentId);
1263
+ paneId = await tmux.getSinglePaneIdByRef(sessionRef);
1167
1264
  }
1168
1265
  catch (err) {
1169
1266
  throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1202,10 +1299,10 @@ export class AgentManager {
1202
1299
  else if (state.kind !== 'shell') {
1203
1300
  throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
1204
1301
  }
1205
- await tmux.killSession(agentId);
1302
+ await this.killAdoptTarget(tmux, agentId, sessionRef, 'workdir change');
1206
1303
  return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1207
1304
  }
1208
- await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
1305
+ await this.setSessionOptions(tmux, agentId, sessionRef, [[WORKDIR_SESSION_OPTION, sessionDir]]);
1209
1306
  switch (state.kind) {
1210
1307
  case 'live-runtime': {
1211
1308
  let stale;
@@ -1223,9 +1320,10 @@ export class AgentManager {
1223
1320
  skillsStale: true,
1224
1321
  paneId,
1225
1322
  workdir,
1323
+ sessionRef,
1226
1324
  };
1227
1325
  }
1228
- return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
1326
+ return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
1229
1327
  }
1230
1328
  case 'startup-dialog':
1231
1329
  throw new EnsureSessionError({
@@ -1247,8 +1345,8 @@ export class AgentManager {
1247
1345
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1248
1346
  scrollback: 0,
1249
1347
  });
1250
- await this.tagSessionSkillsVersion(tmux, agentId);
1251
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
1348
+ await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1349
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1252
1350
  }
1253
1351
  catch (trustErr) {
1254
1352
  if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
@@ -1266,7 +1364,7 @@ export class AgentManager {
1266
1364
  break;
1267
1365
  }
1268
1366
  try {
1269
- await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(agent)}\n`);
1367
+ await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
1270
1368
  await tmux.handleTrustDialog(paneId, runtime, {
1271
1369
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
1272
1370
  });
@@ -1274,8 +1372,8 @@ export class AgentManager {
1274
1372
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1275
1373
  scrollback: 0,
1276
1374
  });
1277
- await this.tagSessionSkillsVersion(tmux, agentId);
1278
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
1375
+ await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1376
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1279
1377
  }
1280
1378
  catch (relErr) {
1281
1379
  if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
@@ -1289,6 +1387,66 @@ export class AgentManager {
1289
1387
  throw new EnsureSessionError({ createdSession: false, agentId }, `REPL relaunch failed: ${relErr instanceof Error ? relErr.message : String(relErr)}`);
1290
1388
  }
1291
1389
  }
1390
+ // Frees a bootstrap blocked on its predecessor's half-created session (nonce
1391
+ // env present but claim never written); anything else is left alone.
1392
+ async reclaimHalfCreatedSession(tmux, agentId, knownSnapshot) {
1393
+ let snapshot;
1394
+ try {
1395
+ snapshot = knownSnapshot ?? await tmux.getSessionSnapshot(agentId);
1396
+ }
1397
+ catch (err) {
1398
+ throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1399
+ }
1400
+ if (!snapshot)
1401
+ return true;
1402
+ if (snapshot.claim !== null)
1403
+ return false;
1404
+ let halfCreated;
1405
+ try {
1406
+ halfCreated = await tmux.hasCreationNonce(agentId);
1407
+ }
1408
+ catch (err) {
1409
+ throw new EnsureSessionError({ createdSession: false, agentId }, `creation-nonce probe failed: ${err instanceof Error ? err.message : String(err)}`);
1410
+ }
1411
+ if (!halfCreated)
1412
+ return false;
1413
+ let outcome;
1414
+ try {
1415
+ // claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
1416
+ outcome = await tmux.killSessionRef(snapshot.ref, { onlyIfUnclaimed: true });
1417
+ }
1418
+ catch (err) {
1419
+ throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
1420
+ }
1421
+ if (outcome === 'refused' || outcome === 'stale-server') {
1422
+ throw new EnsureSessionError({ createdSession: false, agentId }, `session ${snapshot.ref.sessionId} was claimed (or its server changed) while reclaiming the half-created leftover — retry to observe the new state`);
1423
+ }
1424
+ if (outcome === 'killed') {
1425
+ console.warn(`[AgentManager] ${agentId}: killed half-created leftover session ${snapshot.ref.sessionId} (creation nonce present, claim never written)`);
1426
+ }
1427
+ else {
1428
+ console.warn(`[AgentManager] ${agentId}: half-created leftover session ${snapshot.ref.sessionId} already gone`);
1429
+ }
1430
+ return true;
1431
+ }
1432
+ async killAdoptTarget(tmux, agentId, sessionRef, reason) {
1433
+ let outcome;
1434
+ try {
1435
+ outcome = await tmux.killSessionRef(sessionRef);
1436
+ }
1437
+ catch (err) {
1438
+ throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
1439
+ }
1440
+ if (outcome === 'stale-server') {
1441
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux server generation changed while replacing session (${reason}); retry to observe the new state`);
1442
+ }
1443
+ if (outcome === 'killed') {
1444
+ console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
1445
+ }
1446
+ else {
1447
+ console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
1448
+ }
1449
+ }
1292
1450
  async acquireAgentForTask(agentId, taskId, phase) {
1293
1451
  const cfg = this.getAgentConfig(agentId);
1294
1452
  if (!cfg)
@@ -2284,15 +2442,14 @@ export class AgentManager {
2284
2442
  throw new Error(`Unknown agent: ${agentId}`);
2285
2443
  const runner = this.createRunnerFor(cfg);
2286
2444
  const tmux = new TmuxManager(runner);
2287
- const alive = await tmux.hasSession(agentId);
2288
- if (!alive) {
2445
+ const snapshot = await tmux.getSessionSnapshot(agentId);
2446
+ if (!snapshot) {
2289
2447
  throw new Error(`restart-repl: tmux session ${agentId} does not exist; use retry to rebuild`);
2290
2448
  }
2291
- const claim = await tmux.getOption(agentId, '@baxian-agent-id');
2292
- if (claim !== agentId) {
2293
- throw new Error(`restart-repl: session claim mismatch (got "${claim ?? 'null'}"); refusing to touch foreign session`);
2449
+ if (snapshot.claim !== agentId) {
2450
+ throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
2294
2451
  }
2295
- const paneId = await tmux.getSinglePaneId(agentId);
2452
+ const paneId = await tmux.getSinglePaneIdByRef(snapshot.ref);
2296
2453
  await tmux.sendKeysToPane(paneId, 'C-c');
2297
2454
  const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
2298
2455
  const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
@@ -2314,10 +2471,10 @@ export class AgentManager {
2314
2471
  'use retry to rebuild the session safely');
2315
2472
  }
2316
2473
  await this.provisionRepoSkills(runner, cfg, workdir);
2317
- await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, workdir);
2474
+ await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
2318
2475
  const runtime = agentRuntimeKindFor(cfg);
2319
2476
  const relaunch = async () => {
2320
- await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(cfg)}\n`);
2477
+ await tmux.sendKeysToPane(paneId, `${launchCommandIn(workdir, cfg)}\n`);
2321
2478
  await tmux.handleTrustDialog(paneId, runtime, {
2322
2479
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
2323
2480
  });
@@ -2325,7 +2482,7 @@ export class AgentManager {
2325
2482
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
2326
2483
  scrollback: 0,
2327
2484
  });
2328
- await this.tagSessionSkillsVersion(tmux, agentId);
2485
+ await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
2329
2486
  };
2330
2487
  await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
2331
2488
  await this.agentStore.update(agentId, (state) => {
@@ -2419,17 +2576,23 @@ export class AgentManager {
2419
2576
  }
2420
2577
  }
2421
2578
  try {
2422
- const alive = await tmux.hasSession(id);
2423
- if (alive) {
2424
- const claim = await tmux.getOption(id, '@baxian-agent-id');
2425
- if (claim === id) {
2426
- await tmux.killSession(id);
2427
- }
2428
- else {
2579
+ await this.runUnderSessionLifecycle(id, async () => {
2580
+ const snapshot = await tmux.getSessionSnapshot(id);
2581
+ if (!snapshot)
2582
+ return;
2583
+ if (snapshot.claim !== id) {
2429
2584
  console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
2430
- `(claim=${claim ?? 'null'}; not baxian-managed)`);
2585
+ `(claim=${snapshot.claim ?? 'null'}; not baxian-managed)`);
2586
+ return;
2431
2587
  }
2432
- }
2588
+ const outcome = await tmux.killSessionRef(snapshot.ref);
2589
+ if (outcome === 'stale-server') {
2590
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: tmux server generation changed for ${id} — leaving session untouched`);
2591
+ }
2592
+ else if (outcome === 'killed') {
2593
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
2594
+ }
2595
+ });
2433
2596
  }
2434
2597
  catch (err) {
2435
2598
  failures.push({ agentId: id, step: 'tmux', error: err });
@@ -3543,18 +3706,12 @@ export class AgentManager {
3543
3706
  const stillOwner = stateNow?.taskId === taskId
3544
3707
  && stateNow.lockToken === lockToken
3545
3708
  && await this.lockManager.isOwner(agentId, taskId, lockToken);
3546
- try {
3547
- if (stillOwner) {
3548
- const runner = this.createRunnerFor(agent);
3549
- await new TmuxManager(runner).killSession(agentId);
3550
- }
3551
- else {
3552
- console.warn(`[AgentManager] startSession ensureSession rollback skipped killSession for stale owner ` +
3553
- `${agentId}/${taskId}`);
3554
- }
3709
+ if (stillOwner) {
3710
+ await this.rollbackCreatedSession(agentId, err.partial, 'startSession ensure rollback');
3555
3711
  }
3556
- catch (cleanupErr) {
3557
- console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
3712
+ else {
3713
+ console.warn(`[AgentManager] startSession ensureSession rollback skipped for stale owner ` +
3714
+ `${agentId}/${taskId}`);
3558
3715
  }
3559
3716
  }
3560
3717
  throw err;
@@ -3601,7 +3758,7 @@ export class AgentManager {
3601
3758
  if (!ensure.freshRuntime) {
3602
3759
  await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
3603
3760
  if (ensure.skillsStale)
3604
- await this.tagSessionSkillsVersion(tmux, agentId);
3761
+ await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
3605
3762
  }
3606
3763
  }
3607
3764
  catch (err) {
@@ -4018,18 +4175,12 @@ export class AgentManager {
4018
4175
  const stillOwner = stateNow?.taskId === taskId
4019
4176
  && stateNow.lockToken === lockToken
4020
4177
  && await this.lockManager.isOwner(agentId, taskId, lockToken);
4021
- try {
4022
- if (stillOwner) {
4023
- const runner = this.createRunnerFor(agent);
4024
- await new TmuxManager(runner).killSession(agentId);
4025
- }
4026
- else {
4027
- console.warn(`[AgentManager] continueSession ensureSession rollback skipped killSession for stale owner ` +
4028
- `${agentId}/${taskId}`);
4029
- }
4178
+ if (stillOwner) {
4179
+ await this.rollbackCreatedSession(agentId, err.partial, 'continueSession ensure rollback');
4030
4180
  }
4031
- catch (cleanupErr) {
4032
- console.warn(`[AgentManager] continueSession ensureSession rollback killSession failed:`, cleanupErr);
4181
+ else {
4182
+ console.warn(`[AgentManager] continueSession ensureSession rollback skipped for stale owner ` +
4183
+ `${agentId}/${taskId}`);
4033
4184
  }
4034
4185
  }
4035
4186
  throw err;
@@ -4463,13 +4614,7 @@ export class AgentManager {
4463
4614
  continue;
4464
4615
  }
4465
4616
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
4466
- try {
4467
- const runner = this.createRunnerFor(agentConfig);
4468
- await new TmuxManager(runner).killSession(state.id);
4469
- }
4470
- catch (cleanupErr) {
4471
- console.warn(`[recover] killSession rollback failed for agent=${state.id}:`, cleanupErr);
4472
- }
4617
+ await this.rollbackCreatedSession(state.id, err.partial, 'recover ensure rollback');
4473
4618
  }
4474
4619
  const message = err instanceof Error ? err.message : String(err);
4475
4620
  console.warn(`[recover] ensureSession failed for agent=${state.id}: ${message}`);