aicodeman 0.6.11 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/mux-interface.d.ts +2 -0
  2. package/dist/mux-interface.d.ts.map +1 -1
  3. package/dist/session.d.ts +4 -1
  4. package/dist/session.d.ts.map +1 -1
  5. package/dist/session.js +6 -3
  6. package/dist/session.js.map +1 -1
  7. package/dist/tmux-manager.d.ts +15 -0
  8. package/dist/tmux-manager.d.ts.map +1 -1
  9. package/dist/tmux-manager.js +135 -46
  10. package/dist/tmux-manager.js.map +1 -1
  11. package/dist/web/public/api-client.3adebdc2.js.gz +0 -0
  12. package/dist/web/public/{app.6a96cf81.js → app.f67b92cc.js} +4 -4
  13. package/dist/web/public/app.f67b92cc.js.br +0 -0
  14. package/dist/web/public/app.f67b92cc.js.gz +0 -0
  15. package/dist/web/public/constants.cb6426c4.js.gz +0 -0
  16. package/dist/web/public/image-input.926911b4.js.gz +0 -0
  17. package/dist/web/public/index.html +4 -4
  18. package/dist/web/public/index.html.br +0 -0
  19. package/dist/web/public/index.html.gz +0 -0
  20. package/dist/web/public/input-cjk.88082175.js.gz +0 -0
  21. package/dist/web/public/keyboard-accessory.29aebd9c.js.gz +0 -0
  22. package/dist/web/public/mobile-handlers.1e2a8ef8.js.gz +0 -0
  23. package/dist/web/public/mobile.37d62c06.css.gz +0 -0
  24. package/dist/web/public/notification-manager.9c984ac2.js.gz +0 -0
  25. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  26. package/dist/web/public/panels-ui.cf998835.js.gz +0 -0
  27. package/dist/web/public/ralph-panel.61076370.js.gz +0 -0
  28. package/dist/web/public/ralph-wizard.6b0f0be7.js.gz +0 -0
  29. package/dist/web/public/respawn-ui.5377f958.js.gz +0 -0
  30. package/dist/web/public/session-ui.f1555cd1.js.gz +0 -0
  31. package/dist/web/public/settings-ui.25a18120.js.gz +0 -0
  32. package/dist/web/public/{styles.d160ad58.css → styles.c2babdcb.css} +1 -1
  33. package/dist/web/public/styles.c2babdcb.css.br +0 -0
  34. package/dist/web/public/styles.c2babdcb.css.gz +0 -0
  35. package/dist/web/public/subagent-windows.a366a4ad.js.gz +0 -0
  36. package/dist/web/public/sw.js.gz +0 -0
  37. package/dist/web/public/{terminal-ui.5d29101f.js → terminal-ui.7616b9fd.js} +1 -1
  38. package/dist/web/public/terminal-ui.7616b9fd.js.br +0 -0
  39. package/dist/web/public/{terminal-ui.5d29101f.js.gz → terminal-ui.7616b9fd.js.gz} +0 -0
  40. package/dist/web/public/upload.html.gz +0 -0
  41. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  42. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  43. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  44. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  45. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  46. package/dist/web/public/vendor/xterm.css.gz +0 -0
  47. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  48. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  49. package/dist/web/route-error-handler.d.ts +18 -0
  50. package/dist/web/route-error-handler.d.ts.map +1 -0
  51. package/dist/web/route-error-handler.js +19 -0
  52. package/dist/web/route-error-handler.js.map +1 -0
  53. package/dist/web/routes/session-routes.d.ts.map +1 -1
  54. package/dist/web/routes/session-routes.js +3 -1
  55. package/dist/web/routes/session-routes.js.map +1 -1
  56. package/dist/web/server.d.ts.map +1 -1
  57. package/dist/web/server.js +5 -12
  58. package/dist/web/server.js.map +1 -1
  59. package/package.json +1 -1
  60. package/dist/web/public/app.6a96cf81.js.br +0 -0
  61. package/dist/web/public/app.6a96cf81.js.gz +0 -0
  62. package/dist/web/public/styles.d160ad58.css.br +0 -0
  63. package/dist/web/public/styles.d160ad58.css.gz +0 -0
  64. package/dist/web/public/terminal-ui.5d29101f.js.br +0 -0
@@ -45,6 +45,8 @@ const TMUX_KILL_WAIT_MS = 200;
45
45
  const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
46
46
  /** Default stats collection interval (2 seconds) */
47
47
  const DEFAULT_STATS_INTERVAL_MS = 2000;
48
+ /** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
49
+ export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
48
50
  /**
49
51
  * SAFETY: Test mode detection.
50
52
  * When running under vitest (VITEST env var is set automatically),
@@ -67,6 +69,10 @@ const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
67
69
  const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
68
70
  /** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
69
71
  const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
72
+ /** Dedicated tmux socket for new Codeman-owned sessions. */
73
+ const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
74
+ /** Regex to validate tmux socket names passed to `tmux -L`. */
75
+ const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
70
76
  /**
71
77
  * Separator used in `tmux list-panes -F` output between session name and pid.
72
78
  *
@@ -81,6 +87,18 @@ const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
81
87
  const PANE_LIST_SEP = '|';
82
88
  /** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
83
89
  const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
90
+ /**
91
+ * 构建 pane 启动前的 nofile 修复命令。
92
+ *
93
+ * macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile;
94
+ * 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量
95
+ * 或命令替换,因为 fullCmd 目前经由双引号 bash -c 传递,外层
96
+ * shell 会提前展开它们。
97
+ */
98
+ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT) {
99
+ const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
100
+ return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
101
+ }
84
102
  /**
85
103
  * Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
86
104
  * into a Map of session-name → pane pid. Exported for unit testing.
@@ -126,6 +144,28 @@ function isValidPath(path) {
126
144
  }
127
145
  return SAFE_PATH_PATTERN.test(path);
128
146
  }
147
+ // ===========================================================================
148
+ // Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
149
+ // socket (`tmux -L codeman`), isolated from the user's default tmux server.
150
+ // The socket name is a process-wide constant (env-overridable for test/multi-
151
+ // instance isolation) — it is never stored per-session, so it cannot drift.
152
+ // ===========================================================================
153
+ /**
154
+ * Resolve the process-wide Codeman tmux socket name. Always returns a valid
155
+ * name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default.
156
+ */
157
+ function resolveConfiguredTmuxSocket() {
158
+ const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
159
+ if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
160
+ console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
161
+ return DEFAULT_CODEMAN_TMUX_SOCKET;
162
+ }
163
+ return raw;
164
+ }
165
+ /** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
166
+ function tmuxCommand(socket) {
167
+ return `tmux -L ${shellescape(socket)}`;
168
+ }
129
169
  /**
130
170
  * Build Claude CLI permission flags for the tmux command string.
131
171
  * Validates allowedTools to prevent command injection.
@@ -201,7 +241,7 @@ function buildSpawnCommand(options) {
201
241
  * Set sensitive environment variables on a tmux session via setenv.
202
242
  * These are inherited by panes but not visible in ps output or tmux history.
203
243
  */
204
- function setOpenCodeEnvVars(muxName) {
244
+ function setOpenCodeEnvVars(tmuxCmd, muxName) {
205
245
  const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
206
246
  for (const key of sensitiveVars) {
207
247
  const val = process.env[key];
@@ -209,7 +249,7 @@ function setOpenCodeEnvVars(muxName) {
209
249
  // Shell-escape: wrap in single quotes, escape any inner single quotes
210
250
  const escaped = val.replace(/'/g, "'\\''");
211
251
  try {
212
- execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
252
+ execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
213
253
  encoding: 'utf8',
214
254
  timeout: EXEC_TIMEOUT_MS,
215
255
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -225,7 +265,7 @@ function setOpenCodeEnvVars(muxName) {
225
265
  * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
226
266
  * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
227
267
  */
228
- function setOpenCodeConfigContent(muxName, config) {
268
+ function setOpenCodeConfigContent(tmuxCmd, muxName, config) {
229
269
  if (!config)
230
270
  return;
231
271
  let jsonContent;
@@ -257,7 +297,7 @@ function setOpenCodeConfigContent(muxName, config) {
257
297
  if (jsonContent) {
258
298
  const escaped = jsonContent.replace(/'/g, "'\\''");
259
299
  try {
260
- execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
300
+ execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
261
301
  encoding: 'utf8',
262
302
  timeout: EXEC_TIMEOUT_MS,
263
303
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -290,6 +330,7 @@ function setOpenCodeConfigContent(muxName, config) {
290
330
  export class TmuxManager extends EventEmitter {
291
331
  backend = 'tmux';
292
332
  sessions = new Map();
333
+ tmuxSocket = resolveConfiguredTmuxSocket();
293
334
  statsInterval = null;
294
335
  mouseSyncInterval = null;
295
336
  /** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
@@ -302,6 +343,13 @@ export class TmuxManager extends EventEmitter {
302
343
  this.loadSessions();
303
344
  }
304
345
  }
346
+ /** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
347
+ get muxSocket() {
348
+ return this.tmuxSocket;
349
+ }
350
+ tmux() {
351
+ return tmuxCommand(this.tmuxSocket);
352
+ }
305
353
  // Load saved sessions from disk (NEVER called in test mode)
306
354
  loadSessions() {
307
355
  if (IS_TEST_MODE)
@@ -311,8 +359,41 @@ export class TmuxManager extends EventEmitter {
311
359
  const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
312
360
  const data = JSON.parse(content);
313
361
  if (Array.isArray(data)) {
362
+ // Dedup by muxName: one live tmux session must map to exactly one
363
+ // tracked entry. A per-session socket-tag mismatch could historically
364
+ // let the same session be tracked twice — once under its real UUID and
365
+ // once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
366
+ // Single-socket unification removed that failure mode; this pass stays
367
+ // to clean any stale duplicates already on disk. Keep the real (UUID)
368
+ // entry and drop placeholder twins.
369
+ let dropped = 0;
370
+ const keptByMuxName = new Map(); // muxName -> kept sessionId
314
371
  for (const session of data) {
372
+ // Strip the obsolete per-session tmuxSocket tag (now a process-wide
373
+ // constant). Left in place it would be written back by saveSessions()
374
+ // and linger on disk as a zombie field forever.
375
+ delete session.tmuxSocket;
376
+ const muxName = session.muxName;
377
+ const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
378
+ if (priorId) {
379
+ const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
380
+ const priorIsPlaceholder = priorId.startsWith('restored-');
381
+ // Drop the incoming unless it's the real twin of a placeholder we kept.
382
+ if (incomingIsPlaceholder || !priorIsPlaceholder) {
383
+ dropped++;
384
+ continue;
385
+ }
386
+ this.sessions.delete(priorId);
387
+ dropped++;
388
+ }
315
389
  this.sessions.set(session.sessionId, session);
390
+ if (muxName)
391
+ keptByMuxName.set(muxName, session.sessionId);
392
+ }
393
+ // Persist the cleaned list so the stale duplicates don't reload.
394
+ if (dropped > 0) {
395
+ console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`);
396
+ this.saveSessions();
316
397
  }
317
398
  }
318
399
  }
@@ -390,7 +471,7 @@ export class TmuxManager extends EventEmitter {
390
471
  continue;
391
472
  }
392
473
  try {
393
- execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
474
+ execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
394
475
  timeout: EXEC_TIMEOUT_MS,
395
476
  stdio: ['pipe', 'pipe', 'pipe'],
396
477
  });
@@ -422,8 +503,9 @@ export class TmuxManager extends EventEmitter {
422
503
  * (not visible in ps output or tmux history, inherited by panes).
423
504
  */
424
505
  _configureOpenCode(muxName, openCodeConfig) {
425
- setOpenCodeEnvVars(muxName);
426
- setOpenCodeConfigContent(muxName, openCodeConfig);
506
+ const tmuxCmd = this.tmux();
507
+ setOpenCodeEnvVars(tmuxCmd, muxName);
508
+ setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
427
509
  }
428
510
  /**
429
511
  * Creates a new tmux session wrapping Claude CLI or a shell.
@@ -476,7 +558,7 @@ export class TmuxManager extends EventEmitter {
476
558
  const cmd = wrapWithNice(baseCmd, config);
477
559
  try {
478
560
  // Build the full command to run inside tmux
479
- const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
561
+ const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
480
562
  // Create tmux session in three steps to handle cold-start (no server running)
481
563
  // and avoid the race where the command exits before remain-on-exit is set:
482
564
  // 1. Create session with default shell (starts tmux server, stays alive)
@@ -486,7 +568,7 @@ export class TmuxManager extends EventEmitter {
486
568
  // (Production uses systemd which has a clean env, but dev/test may be nested.)
487
569
  const cleanEnv = { ...process.env };
488
570
  delete cleanEnv.TMUX;
489
- execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, {
571
+ execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
490
572
  cwd: workingDir,
491
573
  timeout: EXEC_TIMEOUT_MS,
492
574
  stdio: 'ignore',
@@ -494,7 +576,7 @@ export class TmuxManager extends EventEmitter {
494
576
  });
495
577
  // Set remain-on-exit now that the server is running — must be before respawn-pane
496
578
  try {
497
- execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
579
+ execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
498
580
  timeout: EXEC_TIMEOUT_MS,
499
581
  stdio: 'ignore',
500
582
  });
@@ -511,7 +593,7 @@ export class TmuxManager extends EventEmitter {
511
593
  // so secret values stay off the bash command line. Must run before respawn-pane.
512
594
  this.applyEnvOverrides(muxName, envOverrides);
513
595
  // Replace the shell with the actual command (no echo in terminal)
514
- execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
596
+ execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
515
597
  timeout: EXEC_TIMEOUT_MS,
516
598
  stdio: 'ignore',
517
599
  });
@@ -523,20 +605,20 @@ export class TmuxManager extends EventEmitter {
523
605
  // It gets enabled dynamically when panes are split (agent teams).
524
606
  const configPromises = [
525
607
  // Disable tmux status bar — Codeman's web UI provides session info
526
- execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
608
+ execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
527
609
  .then(() => { })
528
610
  .catch(() => {
529
611
  /* Non-critical — session still works with status bar */
530
612
  }),
531
613
  // Override global remain-on-exit with session-level setting
532
- execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
614
+ execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
533
615
  .then(() => { })
534
616
  .catch(() => {
535
617
  /* Already set globally as fallback */
536
618
  }),
537
619
  // Raise tmux scrollback from its 2000-line default so re-attach preserves
538
620
  // more context. Matches the xterm-side default in constants.js.
539
- execAsync(`tmux set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
621
+ execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
540
622
  .then(() => { })
541
623
  .catch(() => {
542
624
  /* Non-critical — falls back to tmux default */
@@ -544,7 +626,7 @@ export class TmuxManager extends EventEmitter {
544
626
  ];
545
627
  // Enable 24-bit true color passthrough — server-wide, set once per lifetime
546
628
  if (!this.trueColorConfigured) {
547
- configPromises.push(execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
629
+ configPromises.push(execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
548
630
  .then(() => {
549
631
  this.trueColorConfigured = true;
550
632
  })
@@ -594,7 +676,7 @@ export class TmuxManager extends EventEmitter {
594
676
  return null;
595
677
  }
596
678
  try {
597
- const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
679
+ const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
598
680
  encoding: 'utf-8',
599
681
  timeout: EXEC_TIMEOUT_MS,
600
682
  }).trim();
@@ -621,7 +703,7 @@ export class TmuxManager extends EventEmitter {
621
703
  if (!isValidMuxName(muxName))
622
704
  return false;
623
705
  try {
624
- const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
706
+ const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
625
707
  encoding: 'utf-8',
626
708
  timeout: EXEC_TIMEOUT_MS,
627
709
  }).trim();
@@ -658,7 +740,7 @@ export class TmuxManager extends EventEmitter {
658
740
  });
659
741
  const config = niceConfig || DEFAULT_NICE_CONFIG;
660
742
  const cmd = wrapWithNice(baseCmd, config);
661
- const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
743
+ const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
662
744
  try {
663
745
  // For OpenCode: set sensitive env vars via tmux setenv before respawn
664
746
  if (mode === 'opencode') {
@@ -666,7 +748,7 @@ export class TmuxManager extends EventEmitter {
666
748
  }
667
749
  // Re-apply user env overrides before respawn so the new shell inherits them.
668
750
  this.applyEnvOverrides(muxName, envOverrides);
669
- await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
751
+ await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
670
752
  timeout: EXEC_TIMEOUT_MS,
671
753
  });
672
754
  // Wait for the respawned process to start
@@ -685,7 +767,7 @@ export class TmuxManager extends EventEmitter {
685
767
  if (IS_TEST_MODE)
686
768
  return false;
687
769
  try {
688
- execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, {
770
+ execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
689
771
  encoding: 'utf-8',
690
772
  timeout: EXEC_TIMEOUT_MS,
691
773
  });
@@ -814,7 +896,7 @@ export class TmuxManager extends EventEmitter {
814
896
  }
815
897
  // Strategy 3: Kill tmux session by name
816
898
  try {
817
- execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, {
899
+ execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
818
900
  timeout: EXEC_TIMEOUT_MS,
819
901
  });
820
902
  }
@@ -871,26 +953,29 @@ export class TmuxManager extends EventEmitter {
871
953
  const alive = [];
872
954
  const dead = [];
873
955
  const discovered = [];
874
- // Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls)
875
- let activeSessions = new Map();
956
+ // Single batched query against the one socket Codeman owns. With a single
957
+ // socket a session's location is a constant, so there is no per-session
958
+ // socket tag to reconcile and no cross-socket ambiguity that could mark a
959
+ // live session dead (the root cause of vanished/duplicate tabs).
960
+ let active;
876
961
  try {
877
- const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
962
+ const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
878
963
  encoding: 'utf-8',
879
964
  timeout: EXEC_TIMEOUT_MS,
880
965
  }).trim();
881
- activeSessions = parsePaneList(output);
966
+ active = parsePaneList(output);
882
967
  }
883
968
  catch (err) {
884
969
  console.error('[TmuxManager] Failed to list tmux panes:', err);
970
+ active = new Map();
885
971
  }
886
- // Check known sessions against the batch result (O(1) map lookup instead of subprocess per session)
972
+ // Check tracked sessions against the live pane list.
887
973
  for (const [sessionId, session] of this.sessions) {
888
- const pid = activeSessions.get(session.muxName);
974
+ const pid = active.get(session.muxName);
889
975
  if (pid !== undefined) {
890
976
  alive.push(sessionId);
891
- if (pid !== session.pid) {
977
+ if (pid !== session.pid)
892
978
  session.pid = pid;
893
- }
894
979
  }
895
980
  else {
896
981
  dead.push(sessionId);
@@ -898,12 +983,14 @@ export class TmuxManager extends EventEmitter {
898
983
  this.emit('sessionDied', { sessionId });
899
984
  }
900
985
  }
901
- // Discover unknown codeman/claudeman sessions from the same batch result
986
+ // Discover untracked codeman/claudeman sessions on our socket. Dedup by
987
+ // muxName (globally unique) so a name we already track never spawns a
988
+ // second "Restored:" entry.
902
989
  const knownMuxNames = new Set();
903
990
  for (const session of this.sessions.values()) {
904
991
  knownMuxNames.add(session.muxName);
905
992
  }
906
- for (const [sessionName, pid] of activeSessions) {
993
+ for (const [sessionName, pid] of active) {
907
994
  if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-'))
908
995
  continue;
909
996
  if (knownMuxNames.has(sessionName))
@@ -921,6 +1008,7 @@ export class TmuxManager extends EventEmitter {
921
1008
  name: `Restored: ${sessionName}`,
922
1009
  };
923
1010
  this.sessions.set(sessionId, session);
1011
+ knownMuxNames.add(sessionName);
924
1012
  discovered.push(sessionId);
925
1013
  console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
926
1014
  }
@@ -1187,23 +1275,23 @@ export class TmuxManager extends EventEmitter {
1187
1275
  // Ink (Claude CLI's terminal framework) needs them split — sending both in a
1188
1276
  // single tmux invocation (via \;) causes Ink to interpret Enter as a newline
1189
1277
  // character in the input buffer rather than as form submission.
1190
- await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
1278
+ await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
1191
1279
  timeout: EXEC_TIMEOUT_MS,
1192
1280
  });
1193
1281
  await new Promise((resolve) => setTimeout(resolve, 50));
1194
- await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
1282
+ await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
1195
1283
  timeout: EXEC_TIMEOUT_MS,
1196
1284
  });
1197
1285
  }
1198
1286
  else if (textPart) {
1199
1287
  // Text only, no Enter
1200
- await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
1288
+ await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
1201
1289
  timeout: EXEC_TIMEOUT_MS,
1202
1290
  });
1203
1291
  }
1204
1292
  else if (hasCarriageReturn) {
1205
1293
  // Enter only
1206
- await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
1294
+ await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
1207
1295
  timeout: EXEC_TIMEOUT_MS,
1208
1296
  });
1209
1297
  }
@@ -1228,7 +1316,7 @@ export class TmuxManager extends EventEmitter {
1228
1316
  return false;
1229
1317
  }
1230
1318
  try {
1231
- execSync(`tmux set-option -t "${muxName}" mouse on`, {
1319
+ execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
1232
1320
  encoding: 'utf-8',
1233
1321
  timeout: EXEC_TIMEOUT_MS,
1234
1322
  });
@@ -1252,7 +1340,7 @@ export class TmuxManager extends EventEmitter {
1252
1340
  return false;
1253
1341
  }
1254
1342
  try {
1255
- execSync(`tmux set-option -t "${muxName}" mouse off`, {
1343
+ execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
1256
1344
  encoding: 'utf-8',
1257
1345
  timeout: EXEC_TIMEOUT_MS,
1258
1346
  });
@@ -1292,7 +1380,7 @@ export class TmuxManager extends EventEmitter {
1292
1380
  return [];
1293
1381
  }
1294
1382
  try {
1295
- const output = execSync(`tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
1383
+ const output = execSync(`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
1296
1384
  return output
1297
1385
  .split('\n')
1298
1386
  .map((line) => {
@@ -1328,27 +1416,28 @@ export class TmuxManager extends EventEmitter {
1328
1416
  }
1329
1417
  // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
1330
1418
  const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
1419
+ const tmux = this.tmux();
1331
1420
  try {
1332
1421
  const hasCarriageReturn = input.includes('\r');
1333
1422
  const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
1334
1423
  if (textPart && hasCarriageReturn) {
1335
- execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
1424
+ execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
1336
1425
  encoding: 'utf-8',
1337
1426
  timeout: EXEC_TIMEOUT_MS,
1338
1427
  });
1339
- execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
1428
+ execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
1340
1429
  encoding: 'utf-8',
1341
1430
  timeout: EXEC_TIMEOUT_MS,
1342
1431
  });
1343
1432
  }
1344
1433
  else if (textPart) {
1345
- execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
1434
+ execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
1346
1435
  encoding: 'utf-8',
1347
1436
  timeout: EXEC_TIMEOUT_MS,
1348
1437
  });
1349
1438
  }
1350
1439
  else if (hasCarriageReturn) {
1351
- execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
1440
+ execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
1352
1441
  encoding: 'utf-8',
1353
1442
  timeout: EXEC_TIMEOUT_MS,
1354
1443
  });
@@ -1377,7 +1466,7 @@ export class TmuxManager extends EventEmitter {
1377
1466
  }
1378
1467
  const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
1379
1468
  try {
1380
- return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
1469
+ return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
1381
1470
  encoding: 'utf-8',
1382
1471
  timeout: EXEC_TIMEOUT_MS,
1383
1472
  });
@@ -1408,7 +1497,7 @@ export class TmuxManager extends EventEmitter {
1408
1497
  }
1409
1498
  const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
1410
1499
  try {
1411
- execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
1500
+ execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
1412
1501
  encoding: 'utf-8',
1413
1502
  timeout: EXEC_TIMEOUT_MS,
1414
1503
  });
@@ -1435,7 +1524,7 @@ export class TmuxManager extends EventEmitter {
1435
1524
  }
1436
1525
  const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
1437
1526
  try {
1438
- execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
1527
+ execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
1439
1528
  encoding: 'utf-8',
1440
1529
  timeout: EXEC_TIMEOUT_MS,
1441
1530
  });
@@ -1450,7 +1539,7 @@ export class TmuxManager extends EventEmitter {
1450
1539
  return 'tmux';
1451
1540
  }
1452
1541
  getAttachArgs(muxName) {
1453
- return ['attach-session', '-t', muxName];
1542
+ return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
1454
1543
  }
1455
1544
  isAvailable() {
1456
1545
  return TmuxManager.isTmuxAvailable();