botmux 2.52.0 → 2.54.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 (150) hide show
  1. package/README.en.md +22 -269
  2. package/README.md +21 -300
  3. package/dist/adapters/backend/session-backend-selector.d.ts +5 -1
  4. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
  5. package/dist/adapters/backend/session-backend-selector.js +15 -1
  6. package/dist/adapters/backend/session-backend-selector.js.map +1 -1
  7. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  8. package/dist/adapters/backend/tmux-backend.js +3 -0
  9. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  10. package/dist/adapters/backend/types.d.ts +22 -0
  11. package/dist/adapters/backend/types.d.ts.map +1 -1
  12. package/dist/adapters/backend/types.js +7 -1
  13. package/dist/adapters/backend/types.js.map +1 -1
  14. package/dist/adapters/backend/zellij-backend.d.ts +132 -0
  15. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -0
  16. package/dist/adapters/backend/zellij-backend.js +375 -0
  17. package/dist/adapters/backend/zellij-backend.js.map +1 -0
  18. package/dist/adapters/backend/zellij-observe-backend.d.ts +62 -0
  19. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -0
  20. package/dist/adapters/backend/zellij-observe-backend.js +218 -0
  21. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -0
  22. package/dist/adapters/cli/claude-code.d.ts +39 -5
  23. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  24. package/dist/adapters/cli/claude-code.js +53 -31
  25. package/dist/adapters/cli/claude-code.js.map +1 -1
  26. package/dist/adapters/cli/registry.d.ts +2 -1
  27. package/dist/adapters/cli/registry.d.ts.map +1 -1
  28. package/dist/adapters/cli/registry.js +3 -1
  29. package/dist/adapters/cli/registry.js.map +1 -1
  30. package/dist/adapters/cli/seed.d.ts +29 -0
  31. package/dist/adapters/cli/seed.d.ts.map +1 -0
  32. package/dist/adapters/cli/seed.js +63 -0
  33. package/dist/adapters/cli/seed.js.map +1 -0
  34. package/dist/adapters/cli/types.d.ts +17 -1
  35. package/dist/adapters/cli/types.d.ts.map +1 -1
  36. package/dist/bot-registry.d.ts +1 -1
  37. package/dist/bot-registry.d.ts.map +1 -1
  38. package/dist/cli.d.ts.map +1 -1
  39. package/dist/cli.js +79 -49
  40. package/dist/cli.js.map +1 -1
  41. package/dist/config.d.ts +7 -1
  42. package/dist/config.d.ts.map +1 -1
  43. package/dist/config.js +8 -0
  44. package/dist/config.js.map +1 -1
  45. package/dist/core/ask-hook/registry.d.ts.map +1 -1
  46. package/dist/core/ask-hook/registry.js +4 -0
  47. package/dist/core/ask-hook/registry.js.map +1 -1
  48. package/dist/core/command-handler.d.ts +4 -1
  49. package/dist/core/command-handler.d.ts.map +1 -1
  50. package/dist/core/command-handler.js +100 -8
  51. package/dist/core/command-handler.js.map +1 -1
  52. package/dist/core/dispatch.d.ts +33 -0
  53. package/dist/core/dispatch.d.ts.map +1 -1
  54. package/dist/core/dispatch.js +26 -0
  55. package/dist/core/dispatch.js.map +1 -1
  56. package/dist/core/pending-response.d.ts +31 -0
  57. package/dist/core/pending-response.d.ts.map +1 -0
  58. package/dist/core/pending-response.js +87 -0
  59. package/dist/core/pending-response.js.map +1 -0
  60. package/dist/core/session-discovery.d.ts +13 -4
  61. package/dist/core/session-discovery.d.ts.map +1 -1
  62. package/dist/core/session-discovery.js +5 -5
  63. package/dist/core/session-discovery.js.map +1 -1
  64. package/dist/core/session-manager.d.ts +10 -0
  65. package/dist/core/session-manager.d.ts.map +1 -1
  66. package/dist/core/session-manager.js +59 -19
  67. package/dist/core/session-manager.js.map +1 -1
  68. package/dist/core/types.d.ts +8 -2
  69. package/dist/core/types.d.ts.map +1 -1
  70. package/dist/core/types.js.map +1 -1
  71. package/dist/core/worker-pool.d.ts +2 -2
  72. package/dist/core/worker-pool.d.ts.map +1 -1
  73. package/dist/core/worker-pool.js +89 -15
  74. package/dist/core/worker-pool.js.map +1 -1
  75. package/dist/core/zellij-adopt-discovery.d.ts +28 -0
  76. package/dist/core/zellij-adopt-discovery.d.ts.map +1 -0
  77. package/dist/core/zellij-adopt-discovery.js +276 -0
  78. package/dist/core/zellij-adopt-discovery.js.map +1 -0
  79. package/dist/core/zellij-session-discovery.d.ts +73 -0
  80. package/dist/core/zellij-session-discovery.d.ts.map +1 -0
  81. package/dist/core/zellij-session-discovery.js +259 -0
  82. package/dist/core/zellij-session-discovery.js.map +1 -0
  83. package/dist/daemon.d.ts.map +1 -1
  84. package/dist/daemon.js +70 -4
  85. package/dist/daemon.js.map +1 -1
  86. package/dist/dashboard/web/i18n.js +1 -1
  87. package/dist/dashboard/web/i18n.js.map +1 -1
  88. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  89. package/dist/dashboard/web/sessions.js +1 -0
  90. package/dist/dashboard/web/sessions.js.map +1 -1
  91. package/dist/dashboard/web/workflows.js +1 -1
  92. package/dist/dashboard/web/workflows.js.map +1 -1
  93. package/dist/dashboard-web/app.js +3 -3
  94. package/dist/i18n/en.d.ts.map +1 -1
  95. package/dist/i18n/en.js +12 -0
  96. package/dist/i18n/en.js.map +1 -1
  97. package/dist/i18n/zh.d.ts.map +1 -1
  98. package/dist/i18n/zh.js +13 -1
  99. package/dist/i18n/zh.js.map +1 -1
  100. package/dist/im/lark/card-builder.d.ts +7 -1
  101. package/dist/im/lark/card-builder.d.ts.map +1 -1
  102. package/dist/im/lark/card-builder.js +92 -2
  103. package/dist/im/lark/card-builder.js.map +1 -1
  104. package/dist/im/lark/card-handler.d.ts.map +1 -1
  105. package/dist/im/lark/card-handler.js +78 -6
  106. package/dist/im/lark/card-handler.js.map +1 -1
  107. package/dist/im/lark/event-dispatcher.d.ts +8 -1
  108. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  109. package/dist/im/lark/event-dispatcher.js +28 -0
  110. package/dist/im/lark/event-dispatcher.js.map +1 -1
  111. package/dist/im/lark/grant-command.d.ts +13 -0
  112. package/dist/im/lark/grant-command.d.ts.map +1 -1
  113. package/dist/im/lark/grant-command.js +93 -0
  114. package/dist/im/lark/grant-command.js.map +1 -1
  115. package/dist/services/codex-app-threads.d.ts +20 -0
  116. package/dist/services/codex-app-threads.d.ts.map +1 -0
  117. package/dist/services/codex-app-threads.js +165 -0
  118. package/dist/services/codex-app-threads.js.map +1 -0
  119. package/dist/services/pending-response-transaction-store.d.ts +12 -0
  120. package/dist/services/pending-response-transaction-store.d.ts.map +1 -0
  121. package/dist/services/pending-response-transaction-store.js +52 -0
  122. package/dist/services/pending-response-transaction-store.js.map +1 -0
  123. package/dist/services/session-store.d.ts.map +1 -1
  124. package/dist/services/session-store.js +15 -1
  125. package/dist/services/session-store.js.map +1 -1
  126. package/dist/setup/bot-config-editor.d.ts +1 -1
  127. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  128. package/dist/setup/bot-config-editor.js +5 -4
  129. package/dist/setup/bot-config-editor.js.map +1 -1
  130. package/dist/setup/ensure-zellij.d.ts +48 -0
  131. package/dist/setup/ensure-zellij.d.ts.map +1 -0
  132. package/dist/setup/ensure-zellij.js +93 -0
  133. package/dist/setup/ensure-zellij.js.map +1 -0
  134. package/dist/types.d.ts +14 -3
  135. package/dist/types.d.ts.map +1 -1
  136. package/dist/utils/anchor-serializer.d.ts +3 -2
  137. package/dist/utils/anchor-serializer.d.ts.map +1 -1
  138. package/dist/utils/anchor-serializer.js +20 -5
  139. package/dist/utils/anchor-serializer.js.map +1 -1
  140. package/dist/utils/transient-snapshot.js +2 -2
  141. package/dist/utils/transient-snapshot.js.map +1 -1
  142. package/dist/worker.js +235 -32
  143. package/dist/worker.js.map +1 -1
  144. package/dist/workflows/attempt-resume.d.ts +1 -1
  145. package/dist/workflows/attempt-resume.d.ts.map +1 -1
  146. package/dist/workflows/attempt-resume.js +1 -1
  147. package/dist/workflows/attempt-resume.js.map +1 -1
  148. package/dist/workflows/events/payloads.d.ts +20 -20
  149. package/dist/workflows/events/schema.d.ts +48 -48
  150. package/package.json +1 -1
package/dist/worker.js CHANGED
@@ -32,10 +32,14 @@ import { WebSocketServer, WebSocket } from 'ws';
32
32
  import { TerminalRenderer } from './utils/terminal-renderer.js';
33
33
  import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_ROWS, MIN_RENDER_COLS, MIN_RENDER_ROWS, clamp, resolveRenderDimensions, } from './utils/render-dimensions.js';
34
34
  import { createCliAdapterSync } from './adapters/cli/registry.js';
35
- import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds } from './adapters/cli/claude-code.js';
35
+ import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
36
36
  import { mtrSessionIdForBotmuxSession } from './adapters/cli/mtr.js';
37
37
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
38
38
  import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
39
+ import { ZellijBackend, ZELLIJ_CONFIG_KDL } from './adapters/backend/zellij-backend.js';
40
+ import { ZellijObserveBackend } from './adapters/backend/zellij-observe-backend.js';
41
+ import { zellijEnv } from './setup/ensure-zellij.js';
42
+ import { isObserveBackend } from './adapters/backend/types.js';
39
43
  import { selectSessionBackend } from './adapters/backend/session-backend-selector.js';
40
44
  import { tmuxEnv } from './setup/ensure-tmux.js';
41
45
  import { IdleDetector } from './utils/idle-detector.js';
@@ -60,16 +64,35 @@ let isTmuxMode = false;
60
64
  * web-terminal updates flow through the shared scrollback fan-out instead
61
65
  * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
62
66
  let isPipeMode = false;
67
+ /** pty-under-zellij backend (BACKEND_TYPE=zellij). Behaves like the non-tmux
68
+ * pty path for the worker (renderer screenshots, relay web terminal) but owns
69
+ * a persistent zellij session that survives daemon restart. */
70
+ let isZellijMode = false;
63
71
  let httpServer = null;
64
72
  let wss = null;
65
73
  const wsClients = new Set();
66
74
  const authedClients = new WeakSet();
67
- /** Per-WS-client tmux attach PTYs (tmux mode only). */
75
+ /** Per-WS-client tmux/zellij attach PTYs. */
68
76
  const clientPtys = new Map();
69
77
  const writeToken = randomBytes(16).toString('hex');
78
+ /** Lazily-written locked-mode zellij config for per-WS web-terminal attach
79
+ * clients: cleared keybinds + locked mode so every keystroke passes straight
80
+ * to the focused (codex) pane, never intercepted as a zellij shortcut. */
81
+ let zellijAttachCfgPath = null;
82
+ function ensureZellijAttachConfig() {
83
+ if (zellijAttachCfgPath)
84
+ return zellijAttachCfgPath;
85
+ const p = join(process.env.SESSION_DATA_DIR ?? '/tmp', '.zellij-web-attach.kdl');
86
+ try {
87
+ writeFileSync(p, ZELLIJ_CONFIG_KDL);
88
+ }
89
+ catch { /* best effort */ }
90
+ zellijAttachCfgPath = p;
91
+ return p;
92
+ }
70
93
  let sessionId = '';
71
94
  let lastInitConfig = null;
72
- const CLI_DISPLAY_NAMES = { 'claude-code': 'Claude', aiden: 'Aiden', coco: 'CoCo', codex: 'Codex', 'codex-app': 'Codex App', cursor: 'Cursor', gemini: 'Gemini', opencode: 'OpenCode', antigravity: 'Antigravity', mtr: 'MTR', hermes: 'Hermes', mira: 'Mira' };
95
+ const CLI_DISPLAY_NAMES = { 'claude-code': 'Claude', seed: 'Seed', aiden: 'Aiden', coco: 'CoCo', codex: 'Codex', 'codex-app': 'Codex App', cursor: 'Cursor', gemini: 'Gemini', opencode: 'OpenCode', antigravity: 'Antigravity', mtr: 'MTR', hermes: 'Hermes', mira: 'Mira' };
73
96
  function cliName() { return CLI_DISPLAY_NAMES[lastInitConfig?.cliId ?? ''] ?? 'CLI'; }
74
97
  let isPromptReady = false;
75
98
  /** Mutex for async flushPending — prevents concurrent flush loops. */
@@ -147,6 +170,11 @@ let bridgeJsonlDir;
147
170
  * follow the new jsonl. */
148
171
  let bridgeCliPid;
149
172
  let bridgeCliCwd;
173
+ /** Claude-family data root the bridge resolves JSONL / pid-state / tasks
174
+ * against. `~/.claude` for Claude Code; Seed CLI's `.claude-runtime`. Set at
175
+ * bridge start (from the adapter's claudeDataDir); defaults to `~/.claude` so
176
+ * the adopt path and any non-seed caller behave exactly as before. */
177
+ let bridgeDataDir = DEFAULT_CLAUDE_DATA_DIR;
150
178
  /** Last sessionId we observed via the pid resolver — used to detect
151
179
  * rotations cheaply (string compare instead of stat()ing every jsonl). */
152
180
  let bridgeObservedCliSessionId;
@@ -425,7 +453,7 @@ function bridgeAbsorbBaseline() {
425
453
  function bridgeMarkStalePidStateForAcceptedSid(acceptedSid) {
426
454
  if (bridgeCliPid === undefined || bridgeCliCwd === undefined)
427
455
  return;
428
- const pidResolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
456
+ const pidResolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd, bridgeDataDir);
429
457
  if (pidResolved && pidResolved.cliSessionId !== acceptedSid) {
430
458
  bridgeStalePidStateSessionId = pidResolved.cliSessionId;
431
459
  }
@@ -888,7 +916,7 @@ function maybeFollowQuietRotation() {
888
916
  function maybeFollowSessionRotationViaPid() {
889
917
  if (!bridgeCliPid || !bridgeCliCwd)
890
918
  return 'unavailable';
891
- const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
919
+ const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd, bridgeDataDir);
892
920
  if (!resolved)
893
921
  return 'unavailable';
894
922
  if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
@@ -1060,6 +1088,7 @@ function startBridgeWatcher(jsonlPath, opts) {
1060
1088
  bridgeJsonlDir = dirname(jsonlPath);
1061
1089
  bridgeCliPid = opts?.cliPid;
1062
1090
  bridgeCliCwd = opts?.cliCwd;
1091
+ bridgeDataDir = opts?.dataDir ?? DEFAULT_CLAUDE_DATA_DIR;
1063
1092
  const mode = opts?.mode ?? 'baseline-existing';
1064
1093
  // Pid-state record ranks above the path the adopt scan computed. If
1065
1094
  // Claude was launched with `--resume` (or the adopt scan picked a
@@ -1067,7 +1096,7 @@ function startBridgeWatcher(jsonlPath, opts) {
1067
1096
  // and we swap to it before baseline so we don't waste a baseline on
1068
1097
  // a frozen file.
1069
1098
  if (bridgeCliPid && bridgeCliCwd) {
1070
- const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
1099
+ const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd, bridgeDataDir);
1071
1100
  if (resolved) {
1072
1101
  bridgeObservedCliSessionId = resolved.cliSessionId;
1073
1102
  bridgeRememberSessionIdForPath(resolved.path);
@@ -1092,10 +1121,10 @@ function startBridgeWatcher(jsonlPath, opts) {
1092
1121
  // continuously for the active session, so this catches the rotation
1093
1122
  // even between writes). `findOpenClaudeSessionIds` unions both.
1094
1123
  if (bridgeCliPid !== undefined && bridgeJsonlDir && bridgeCliCwd) {
1095
- const sids = findOpenClaudeSessionIds(bridgeCliPid);
1124
+ const sids = findOpenClaudeSessionIds(bridgeCliPid, bridgeDataDir);
1096
1125
  const candidates = [];
1097
1126
  for (const sid of sids) {
1098
- const path = claudeJsonlPathForSession(sid, bridgeCliCwd);
1127
+ const path = claudeJsonlPathForSession(sid, bridgeCliCwd, bridgeDataDir);
1099
1128
  bridgeRememberSessionIdForPath(path);
1100
1129
  if (existsSyncSafe(path))
1101
1130
  candidates.push(path);
@@ -2736,19 +2765,22 @@ function stopScreenUpdates() {
2736
2765
  }
2737
2766
  // ─── PTY Management ──────────────────────────────────────────────────────────
2738
2767
  function spawnCli(cfg) {
2739
- // ── Adopt mode: pipe-pane the user's existing tmux pane (no attach) ──
2740
- if (cfg.adoptMode && cfg.adoptTmuxTarget) {
2768
+ // ── Adopt mode: observe the user's existing pane (no attach / non-invasive) ──
2769
+ // tmux: pipe-pane (raw stream). zellij: dump-screen poll + action drive.
2770
+ if (cfg.adoptMode && (cfg.adoptTmuxTarget || cfg.adoptZellijPaneId)) {
2741
2771
  // We mark BOTH isTmuxMode and isPipeMode: the former keeps idle/spawn
2742
- // logic on the tmux track; the latter tells the WS handler to route
2772
+ // logic on the observe track; the latter tells the WS handler to route
2743
2773
  // updates through the shared scrollback fan-out (because there is no
2744
2774
  // PTY-per-WS — we don't attach to anything).
2745
2775
  isTmuxMode = true;
2746
2776
  isPipeMode = true;
2747
2777
  const cols = cfg.adoptPaneCols ?? PTY_COLS;
2748
2778
  const rows = cfg.adoptPaneRows ?? PTY_ROWS;
2749
- const pipeBe = new TmuxPipeBackend(cfg.adoptTmuxTarget);
2750
- backend = pipeBe;
2751
- pipeBe.spawn('', [], {
2779
+ const observeBe = cfg.adoptZellijPaneId
2780
+ ? new ZellijObserveBackend(cfg.adoptZellijSession ?? '', cfg.adoptZellijPaneId, { cliPid: cfg.adoptCliPid })
2781
+ : new TmuxPipeBackend(cfg.adoptTmuxTarget);
2782
+ backend = observeBe;
2783
+ observeBe.spawn('', [], {
2752
2784
  cwd: cfg.workingDir,
2753
2785
  cols,
2754
2786
  rows,
@@ -2756,9 +2788,9 @@ function spawnCli(cfg) {
2756
2788
  });
2757
2789
  // Seed the shared scrollback with the pane's current screen so any
2758
2790
  // already-connected (or future) WS clients render meaningful content
2759
- // immediately, instead of waiting for the next byte tmux pipes through.
2791
+ // immediately, instead of waiting for the next observe tick.
2760
2792
  try {
2761
- const initial = pipeBe.captureCurrentScreen();
2793
+ const initial = observeBe.captureCurrentScreen();
2762
2794
  if (initial.length > 0)
2763
2795
  onPtyData(initial);
2764
2796
  }
@@ -2932,14 +2964,19 @@ function spawnCli(cfg) {
2932
2964
  // the worker re-probes here. If tmux can't start a server we silently
2933
2965
  // fall back to PTY rather than letting attach-session / new-session spam
2934
2966
  // the daemon error log every poll cycle.
2935
- let useTmux = cfg.backendType === 'tmux';
2936
- if (useTmux && !TmuxBackend.isAvailable()) {
2967
+ let effectiveBackend = cfg.backendType;
2968
+ if (effectiveBackend === 'tmux' && !TmuxBackend.isAvailable()) {
2937
2969
  log('tmux backend requested but functional probe failed — falling back to PTY backend');
2938
- useTmux = false;
2970
+ effectiveBackend = 'pty';
2939
2971
  }
2940
- const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, useTmux });
2972
+ if (effectiveBackend === 'zellij' && !ZellijBackend.isAvailable()) {
2973
+ log('zellij backend requested but functional probe failed (need zellij >= 0.44) — falling back to PTY backend');
2974
+ effectiveBackend = 'pty';
2975
+ }
2976
+ const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, backendType: effectiveBackend });
2941
2977
  isTmuxMode = selectedBackend.isTmuxMode;
2942
2978
  isPipeMode = selectedBackend.isPipeMode;
2979
+ isZellijMode = selectedBackend.isZellijMode;
2943
2980
  backend = selectedBackend.backend;
2944
2981
  const adapterSessionId = cfg.resume
2945
2982
  ? (cfg.originalSessionId ?? cfg.sessionId)
@@ -2949,9 +2986,15 @@ function spawnCli(cfg) {
2949
2986
  // actually committed (rather than trusting a fixed sleep), so wire it up now.
2950
2987
  // Codex's adapter uses ~/.codex/history.jsonl (a fixed global path) directly,
2951
2988
  // so it needs no per-session wiring here.
2952
- if (cfg.cliId === 'claude-code') {
2989
+ //
2990
+ // `claudeDataDir` is the Claude-family marker: set for claude-code AND its
2991
+ // forks (Seed → `.claude-runtime`), undefined for everything else. Every
2992
+ // JSONL/pid/bridge gate below keys off it instead of `cliId === 'claude-code'`,
2993
+ // so a fork inherits the whole submit-confirm + bridge-fallback machinery.
2994
+ const claudeDataDir = cliAdapter.claudeDataDir;
2995
+ if (claudeDataDir) {
2953
2996
  backend.claudeJsonlPath =
2954
- claudeJsonlPathForSession(cfg.cliSessionId ?? adapterSessionId, cfg.workingDir);
2997
+ claudeJsonlPathForSession(cfg.cliSessionId ?? adapterSessionId, cfg.workingDir, claudeDataDir);
2955
2998
  }
2956
2999
  const args = cliAdapter.buildArgs({
2957
3000
  sessionId: adapterSessionId,
@@ -2971,12 +3014,13 @@ function spawnCli(cfg) {
2971
3014
  args.push(...extra.split(/\s+/).filter(Boolean));
2972
3015
  // Claude Code 在 root/sudo 下会拒绝 --dangerously-skip-permissions 并立即 exit。
2973
3016
  // botmux 必须带这个 flag(话题里没法弹交互式审批),所以为 root 自动注入
2974
- // IS_SANDBOX=1 走 Claude Code 的受控环境逃生舱。用户显式设了就尊重不覆盖。
2975
- const injectClaudeSandbox = cfg.cliId === 'claude-code' &&
3017
+ // IS_SANDBOX=1 走 Claude Code 的受控环境逃生舱。Seed 是 Claude Code fork,同样
3018
+ // 受此限制 claude 家族判断。用户显式设了就尊重不覆盖。
3019
+ const injectClaudeSandbox = !!claudeDataDir &&
2976
3020
  process.getuid?.() === 0 &&
2977
3021
  !process.env.IS_SANDBOX;
2978
3022
  if (injectClaudeSandbox) {
2979
- log('Detected root user — injecting IS_SANDBOX=1 for Claude Code');
3023
+ log('Detected root user — injecting IS_SANDBOX=1 for Claude-family CLI');
2980
3024
  }
2981
3025
  // Claude Code 2.1.x:`--resume` 一个「空闲 >70min 且累计 >10 万 token」的会话会弹
2982
3026
  // 交互式菜单(Resume from summary / full / Don't ask again),botmux 无法导航 →
@@ -2984,7 +3028,8 @@ function spawnCli(cfg) {
2984
3028
  // 而 return null → 菜单不弹、按 full session 原样续(走 summary 会触发 /compact,
2985
3029
  // 破坏 bridge 的会话连续性追踪)。用户显式设了就尊重。注意:该 key 必须同时进
2986
3030
  // BOTMUX_INJECTED_ENV_KEYS 白名单,否则 tmux backend 不会把它透传进 pane。
2987
- const claudeResumeTokenThreshold = cfg.cliId === 'claude-code'
3031
+ // Seed Claude Code fork,同样有 resume-summary 菜单 → 按 claude 家族判断。
3032
+ const claudeResumeTokenThreshold = claudeDataDir
2988
3033
  ? process.env.CLAUDE_CODE_RESUME_TOKEN_THRESHOLD ?? '2147483647'
2989
3034
  : undefined;
2990
3035
  // Predict reattach vs fresh so the log line tells the truth. When a bmx-*
@@ -2993,9 +3038,13 @@ function spawnCli(cfg) {
2993
3038
  // is misleading and has cost real debugging time. (CliId-mismatch reattach
2994
3039
  // is now blocked upstream in restoreActiveSessions / killStalePids.)
2995
3040
  const willReattachTmux = isTmuxMode && TmuxBackend.hasSession(TmuxBackend.sessionName(cfg.sessionId));
3041
+ const willReattachZellij = isZellijMode && ZellijBackend.hasSession(ZellijBackend.sessionName(cfg.sessionId));
2996
3042
  if (willReattachTmux) {
2997
3043
  log(`Re-attaching to existing tmux session: ${TmuxBackend.sessionName(cfg.sessionId)} (requested CLI: ${cliAdapter.resolvedBin})`);
2998
3044
  }
3045
+ else if (willReattachZellij) {
3046
+ log(`Re-attaching to existing zellij session: ${ZellijBackend.sessionName(cfg.sessionId)} (requested CLI: ${cliAdapter.resolvedBin})`);
3047
+ }
2999
3048
  else {
3000
3049
  log(`Spawning fresh CLI: ${cliAdapter.resolvedBin} ${args.join(' ')} (cwd: ${cfg.workingDir})`);
3001
3050
  }
@@ -3021,6 +3070,12 @@ function spawnCli(cfg) {
3021
3070
  childEnv.IS_SANDBOX = '1';
3022
3071
  if (claudeResumeTokenThreshold)
3023
3072
  childEnv.CLAUDE_CODE_RESUME_TOKEN_THRESHOLD = claudeResumeTokenThreshold;
3073
+ // Adapter-supplied env: points Claude-family forks at their data root (Seed's
3074
+ // CLAUDE_CONFIG_DIR → `.claude-runtime`). Keys here are also in the tmux
3075
+ // passthrough whitelist (BOTMUX_INJECTED_ENV_KEYS) so the tmux backend forwards
3076
+ // them past the server's global env.
3077
+ if (cliAdapter.spawnEnv)
3078
+ Object.assign(childEnv, cliAdapter.spawnEnv);
3024
3079
  backend.spawn(cliAdapter.resolvedBin, args, {
3025
3080
  cwd: cfg.workingDir,
3026
3081
  cols: PTY_COLS,
@@ -3051,10 +3106,47 @@ function spawnCli(cfg) {
3051
3106
  // "no rotation at all". The pinned claudeJsonlPath above is still the
3052
3107
  // initial guess; the resolver corrects it on first write when Claude was
3053
3108
  // started with `--resume`.
3054
- if (cfg.cliId === 'claude-code' && cliPid) {
3109
+ if (claudeDataDir && cliPid) {
3055
3110
  backend.cliPid = cliPid;
3056
3111
  backend.cliCwd = cfg.workingDir;
3057
3112
  }
3113
+ // Async pid fallback: tmux/pty resolve the CLI pid synchronously above, but
3114
+ // zellij's CLI subprocess starts AFTER spawn() returns (the zellij server
3115
+ // forks the pane asynchronously), so getChildPid() is null right now. Without
3116
+ // the marker, an in-CLI `botmux send` walks ancestor pids, finds no match,
3117
+ // and reports "无法推断 session-id". Retry briefly (non-blocking — a sync wait
3118
+ // would lose zellij's initial render since node-pty doesn't buffer pre-listener
3119
+ // output) until the pid appears, then write the marker + wire claude-family pid.
3120
+ if (!cliPid) {
3121
+ let attempts = 0;
3122
+ const resolveCliPidLate = () => {
3123
+ if (!backend)
3124
+ return;
3125
+ const pid = backend.getChildPid?.();
3126
+ if (pid) {
3127
+ if (process.env.SESSION_DATA_DIR && !cliPidMarker) {
3128
+ try {
3129
+ const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
3130
+ mkdirSync(markersDir, { recursive: true });
3131
+ cliPidMarker = join(markersDir, String(pid));
3132
+ writeFileSync(cliPidMarker, cfg.sessionId);
3133
+ log(`CLI PID marker written (async): ${pid}`);
3134
+ }
3135
+ catch (err) {
3136
+ log(`Failed to write CLI PID marker (async): ${err.message}`);
3137
+ }
3138
+ }
3139
+ if (claudeDataDir) {
3140
+ backend.cliPid = pid;
3141
+ backend.cliCwd = cfg.workingDir;
3142
+ }
3143
+ return;
3144
+ }
3145
+ if (++attempts < 25)
3146
+ setTimeout(resolveCliPidLate, 120); // ~3s budget
3147
+ };
3148
+ setTimeout(resolveCliPidLate, 120);
3149
+ }
3058
3150
  // On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
3059
3151
  // suppressed until the idle detector fires markNewTurn() — this prevents the
3060
3152
  // full tmux scrollback history from leaking into the streaming card.
@@ -3066,13 +3158,14 @@ function spawnCli(cfg) {
3066
3158
  // the file Claude creates on first submit isn't absorbed as history,
3067
3159
  // and baseline-existing on resume so prior-run turns ARE absorbed (we
3068
3160
  // don't want to re-emit yesterday's conversation as fresh turns).
3069
- if (cfg.cliId === 'claude-code' && adapterSessionId) {
3161
+ if (claudeDataDir && adapterSessionId) {
3070
3162
  const claudeBridgeSessionId = cfg.cliSessionId ?? adapterSessionId;
3071
- const claudeJsonl = claudeJsonlPathForSession(claudeBridgeSessionId, cfg.workingDir);
3163
+ const claudeJsonl = claudeJsonlPathForSession(claudeBridgeSessionId, cfg.workingDir, claudeDataDir);
3072
3164
  startBridgeWatcher(claudeJsonl, {
3073
3165
  cliPid: cliPid ?? undefined,
3074
3166
  cliCwd: cfg.workingDir,
3075
3167
  mode: cfg.resume ? 'baseline-existing' : 'fresh-empty',
3168
+ dataDir: claudeDataDir,
3076
3169
  });
3077
3170
  }
3078
3171
  // Structured transcript bridge fallback: if the model finishes without
@@ -3301,6 +3394,84 @@ function startWebServer(host, preferredPort) {
3301
3394
  }
3302
3395
  });
3303
3396
  }
3397
+ else if (lastInitConfig?.adoptMode && lastInitConfig?.adoptZellijPaneId) {
3398
+ // ── Zellij-adopt per-WS attach ──
3399
+ // Each WS client gets its own `zellij attach` PTY sized to the browser.
3400
+ // zellij sizes the (shared) pane to the SMALLEST attached client, so
3401
+ // when the user's terminal is detached the web client governs the size
3402
+ // → fully browser-responsive (申晗's insight, verified), never resizing
3403
+ // the user's terminal beyond min(theirs, browser). Locked-mode config
3404
+ // (cleared keybinds) makes every keystroke reach the codex pane instead
3405
+ // of being swallowed as a zellij shortcut. Bonus: raw byte stream — none
3406
+ // of the dump-screen snapshot / \r\n / fixed-width machinery the relay
3407
+ // needs. (The Lark screenshot card still uses the dump-screen
3408
+ // ObserveBackend; unaffected.) Deferred until first resize, same as tmux.
3409
+ const zSession = lastInitConfig.adoptZellijSession ?? '';
3410
+ const cfgPath = ensureZellijAttachConfig();
3411
+ let cp = null;
3412
+ const pendingInput = [];
3413
+ const startAttach = (cols, rows) => {
3414
+ if (cp)
3415
+ return;
3416
+ cp = pty.spawn('zellij', ['--config', cfgPath, 'attach', zSession], {
3417
+ name: 'xterm-256color',
3418
+ cols,
3419
+ rows,
3420
+ env: zellijEnv(),
3421
+ });
3422
+ clientPtys.set(ws, cp);
3423
+ cp.onData((d) => {
3424
+ if (ws.readyState === WebSocket.OPEN)
3425
+ ws.send(d);
3426
+ });
3427
+ cp.onExit(() => {
3428
+ clientPtys.delete(ws);
3429
+ if (ws.readyState === WebSocket.OPEN)
3430
+ ws.close();
3431
+ });
3432
+ for (const data of pendingInput)
3433
+ cp.write(data);
3434
+ pendingInput.length = 0;
3435
+ };
3436
+ const spawnTimer = setTimeout(() => startAttach(150, 40), 500);
3437
+ ws.on('message', (raw) => {
3438
+ try {
3439
+ const msg = JSON.parse(String(raw));
3440
+ if (msg.type === 'resize' && msg.cols > 0 && msg.rows > 0) {
3441
+ if (!cp) {
3442
+ clearTimeout(spawnTimer);
3443
+ startAttach(msg.cols, msg.rows);
3444
+ }
3445
+ else
3446
+ cp.resize(msg.cols, msg.rows);
3447
+ }
3448
+ else if (msg.type === 'input' && typeof msg.data === 'string') {
3449
+ if (!authedClients.has(ws)) {
3450
+ // Read-only: only let mouse events (scroll/select) through.
3451
+ if (!/^\x1b\[([<M])/.test(msg.data))
3452
+ return;
3453
+ }
3454
+ if (cp)
3455
+ cp.write(msg.data);
3456
+ else
3457
+ pendingInput.push(msg.data);
3458
+ }
3459
+ }
3460
+ catch { /* ignore non-JSON or bad messages */ }
3461
+ });
3462
+ ws.on('close', () => {
3463
+ clearTimeout(spawnTimer);
3464
+ wsClients.delete(ws);
3465
+ const existing = clientPtys.get(ws);
3466
+ if (existing) {
3467
+ try {
3468
+ existing.kill();
3469
+ }
3470
+ catch { /* already dead */ }
3471
+ clientPtys.delete(ws);
3472
+ }
3473
+ });
3474
+ }
3304
3475
  else {
3305
3476
  // ── Shared relay (PtyBackend OR tmux pipe mode) ──
3306
3477
  // History seed: prefer tmux's authoritative capture-pane in pipe mode
@@ -3308,8 +3479,20 @@ function startWebServer(host, preferredPort) {
3308
3479
  // stream, which scrolls stale Ink redraw/spinner frames into scrollback
3309
3480
  // at any size mismatch and produces the stacked-footer history garble.
3310
3481
  // See chooseWebTerminalSeed for the full rationale.
3482
+ // Adopt observes a pane we CANNOT resize (tmux adopt has
3483
+ // ownsSession=false so resize() is a no-op; zellij drives via
3484
+ // dump-screen). The client's FitAddon sizes its xterm to the browser,
3485
+ // but the snapshot lines carry the PANE's width — any mismatch wraps the
3486
+ // full-width TUI box lines and garbles the layout (the misalignment 申晗
3487
+ // saw). Pin the client xterm to the pane's fixed size via a botmux OSC
3488
+ // (sent BEFORE the seed so the client resizes before rendering it).
3489
+ if (lastInitConfig?.adoptMode && isObserveBackend(backend)) {
3490
+ const sz = backend.getPaneSize();
3491
+ if (sz && sz.cols > 0 && sz.rows > 0)
3492
+ ws.send(`\x1b]1989;${sz.cols};${sz.rows}\x07`);
3493
+ }
3311
3494
  const seed = chooseWebTerminalSeed({
3312
- canCapture: isPipeMode && backend instanceof TmuxPipeBackend,
3495
+ canCapture: isPipeMode && isObserveBackend(backend),
3313
3496
  capture: () => backend.captureCurrentScreen(),
3314
3497
  scrollback,
3315
3498
  onError: log,
@@ -3502,8 +3685,24 @@ term.onData(function(d){
3502
3685
  }
3503
3686
  if(ws_&&ws_.readyState===1)ws_.send(JSON.stringify({type:'input',data:d}));
3504
3687
  });
3505
- function sendResize(){if(ws_&&ws_.readyState===1)ws_.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}))}
3506
- window.addEventListener('resize',function(){fit.fit();sendResize()});
3688
+ var fixedSize=false,_lastC=0,_lastR=0,_rzT=0;
3689
+ function sendResize(){
3690
+ if(!ws_||ws_.readyState!==1)return;
3691
+ // Dedup: a fit that lands on the same grid must NOT re-emit a resize — for a
3692
+ // zellij/tmux attach client that would reflow the shared pane for nothing.
3693
+ if(term.cols===_lastC&&term.rows===_lastR)return;
3694
+ _lastC=term.cols;_lastR=term.rows;
3695
+ ws_.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));
3696
+ }
3697
+ // Debounce viewport resize: mobile fires a burst of window.resize as the address
3698
+ // bar / on-screen keyboard show & hide, and an un-debounced fit→resize on each
3699
+ // reflows the (shared) zellij pane every frame — the status bar toggles and the
3700
+ // text re-wraps, i.e. the flicker 申晗 saw. Coalesce to the settled size.
3701
+ function onViewportResize(){
3702
+ clearTimeout(_rzT);
3703
+ _rzT=setTimeout(function(){if(!fixedSize){try{fit.fit()}catch(e){}}sendResize()},250);
3704
+ }
3705
+ window.addEventListener('resize',onViewportResize);
3507
3706
  (function connect(){
3508
3707
  var t=new URLSearchParams(location.search).get('token')||'';
3509
3708
  // Derive base from the current path so the WS connects to the same prefix the
@@ -3516,6 +3715,10 @@ window.addEventListener('resize',function(){fit.fit();sendResize()});
3516
3715
  ws.onopen=function(){el.textContent='connected';el.className='ok';sendResize()};
3517
3716
  ws.onmessage=function(e){
3518
3717
  var data=typeof e.data==='string'?e.data:new TextDecoder().decode(e.data);
3718
+ // botmux OSC 1989: pin the xterm to the adopted pane's fixed size (the pane
3719
+ // can't be resized, so FitAddon-to-browser would wrap the snapshot lines).
3720
+ var _fs=data.match(/\\x1b\\]1989;(\\d+);(\\d+)\\x07/);
3721
+ if(_fs){fixedSize=true;var _c=+_fs[1],_r=+_fs[2];if(_c>0&&_r>0){try{term.resize(_c,_r)}catch(ex){}}data=data.replace(_fs[0],'')}
3519
3722
  // Intercept OSC 52 clipboard sequence from tmux (set-clipboard on)
3520
3723
  var m=data.match(/\\x1b\\]52;[^;]*;([A-Za-z0-9+/=]+)(?:\\x07|\\x1b\\\\)/);
3521
3724
  if(m){try{_clipBuf=new TextDecoder().decode(Uint8Array.from(atob(m[1]),function(c){return c.charCodeAt(0)}));_doCopy(_clipBuf);_showCopied()}catch(ex){}}