botmux 2.55.0 → 2.56.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 (103) hide show
  1. package/dist/adapters/backend/herdr-backend.d.ts +80 -0
  2. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -0
  3. package/dist/adapters/backend/herdr-backend.js +519 -0
  4. package/dist/adapters/backend/herdr-backend.js.map +1 -0
  5. package/dist/adapters/backend/session-backend-selector.d.ts +2 -2
  6. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
  7. package/dist/adapters/backend/session-backend-selector.js +19 -1
  8. package/dist/adapters/backend/session-backend-selector.js.map +1 -1
  9. package/dist/adapters/backend/tmux-pipe-backend.d.ts +29 -0
  10. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  11. package/dist/adapters/backend/tmux-pipe-backend.js +51 -4
  12. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  13. package/dist/adapters/backend/types.d.ts +7 -0
  14. package/dist/adapters/backend/types.d.ts.map +1 -1
  15. package/dist/adapters/backend/types.js.map +1 -1
  16. package/dist/adapters/cli/codex.d.ts.map +1 -1
  17. package/dist/adapters/cli/codex.js +11 -0
  18. package/dist/adapters/cli/codex.js.map +1 -1
  19. package/dist/bot-registry.d.ts +2 -1
  20. package/dist/bot-registry.d.ts.map +1 -1
  21. package/dist/bot-registry.js.map +1 -1
  22. package/dist/cli.d.ts.map +1 -1
  23. package/dist/cli.js +262 -15
  24. package/dist/cli.js.map +1 -1
  25. package/dist/config.d.ts +2 -1
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +13 -3
  28. package/dist/config.js.map +1 -1
  29. package/dist/core/command-handler.d.ts.map +1 -1
  30. package/dist/core/command-handler.js +12 -7
  31. package/dist/core/command-handler.js.map +1 -1
  32. package/dist/core/pending-response.d.ts +16 -1
  33. package/dist/core/pending-response.d.ts.map +1 -1
  34. package/dist/core/pending-response.js +19 -1
  35. package/dist/core/pending-response.js.map +1 -1
  36. package/dist/core/session-discovery.d.ts +20 -4
  37. package/dist/core/session-discovery.d.ts.map +1 -1
  38. package/dist/core/session-discovery.js +228 -72
  39. package/dist/core/session-discovery.js.map +1 -1
  40. package/dist/core/session-manager.d.ts +2 -1
  41. package/dist/core/session-manager.d.ts.map +1 -1
  42. package/dist/core/session-manager.js +77 -45
  43. package/dist/core/session-manager.js.map +1 -1
  44. package/dist/core/types.d.ts +8 -1
  45. package/dist/core/types.d.ts.map +1 -1
  46. package/dist/core/types.js.map +1 -1
  47. package/dist/core/worker-pool.d.ts.map +1 -1
  48. package/dist/core/worker-pool.js +74 -62
  49. package/dist/core/worker-pool.js.map +1 -1
  50. package/dist/daemon.js +4 -4
  51. package/dist/daemon.js.map +1 -1
  52. package/dist/i18n/en.d.ts.map +1 -1
  53. package/dist/i18n/en.js +1 -0
  54. package/dist/i18n/en.js.map +1 -1
  55. package/dist/i18n/zh.d.ts.map +1 -1
  56. package/dist/i18n/zh.js +1 -0
  57. package/dist/i18n/zh.js.map +1 -1
  58. package/dist/im/lark/card-builder.d.ts +2 -1
  59. package/dist/im/lark/card-builder.d.ts.map +1 -1
  60. package/dist/im/lark/card-builder.js +19 -2
  61. package/dist/im/lark/card-builder.js.map +1 -1
  62. package/dist/im/lark/card-handler.d.ts.map +1 -1
  63. package/dist/im/lark/card-handler.js +5 -3
  64. package/dist/im/lark/card-handler.js.map +1 -1
  65. package/dist/im/lark/event-dispatcher.js +3 -3
  66. package/dist/im/lark/event-dispatcher.js.map +1 -1
  67. package/dist/setup/agent-preset.d.ts +78 -0
  68. package/dist/setup/agent-preset.d.ts.map +1 -0
  69. package/dist/setup/agent-preset.js +127 -0
  70. package/dist/setup/agent-preset.js.map +1 -0
  71. package/dist/setup/bot-config-editor.js +2 -2
  72. package/dist/setup/bot-config-editor.js.map +1 -1
  73. package/dist/setup/ensure-herdr-integrations.d.ts +26 -0
  74. package/dist/setup/ensure-herdr-integrations.d.ts.map +1 -0
  75. package/dist/setup/ensure-herdr-integrations.js +127 -0
  76. package/dist/setup/ensure-herdr-integrations.js.map +1 -0
  77. package/dist/setup/ensure-herdr.d.ts +12 -0
  78. package/dist/setup/ensure-herdr.d.ts.map +1 -0
  79. package/dist/setup/ensure-herdr.js +70 -0
  80. package/dist/setup/ensure-herdr.js.map +1 -0
  81. package/dist/setup/ensure-opus.d.ts +13 -0
  82. package/dist/setup/ensure-opus.d.ts.map +1 -0
  83. package/dist/setup/ensure-opus.js +64 -0
  84. package/dist/setup/ensure-opus.js.map +1 -0
  85. package/dist/setup/ensure-tmux.d.ts +13 -0
  86. package/dist/setup/ensure-tmux.d.ts.map +1 -1
  87. package/dist/setup/ensure-tmux.js +4 -4
  88. package/dist/setup/ensure-tmux.js.map +1 -1
  89. package/dist/setup/index.d.ts +4 -0
  90. package/dist/setup/index.d.ts.map +1 -1
  91. package/dist/setup/index.js +78 -1
  92. package/dist/setup/index.js.map +1 -1
  93. package/dist/types.d.ts +14 -2
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/utils/transient-snapshot.d.ts +3 -3
  96. package/dist/utils/transient-snapshot.js +3 -3
  97. package/dist/worker.js +251 -179
  98. package/dist/worker.js.map +1 -1
  99. package/dist/workflows/attempt-resume.d.ts +2 -1
  100. package/dist/workflows/attempt-resume.d.ts.map +1 -1
  101. package/dist/workflows/attempt-resume.js.map +1 -1
  102. package/dist/workflows/definition.d.ts +22 -22
  103. package/package.json +1 -1
package/dist/worker.js CHANGED
@@ -34,6 +34,7 @@ import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_R
34
34
  import { createCliAdapterSync } from './adapters/cli/registry.js';
35
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
+ import { HerdrBackend } from './adapters/backend/herdr-backend.js';
37
38
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
38
39
  import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
39
40
  import { ZellijBackend, ZELLIJ_CONFIG_KDL } from './adapters/backend/zellij-backend.js';
@@ -64,6 +65,7 @@ let isTmuxMode = false;
64
65
  * web-terminal updates flow through the shared scrollback fan-out instead
65
66
  * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
66
67
  let isPipeMode = false;
68
+ let effectiveBackendType = 'pty';
67
69
  /** pty-under-zellij backend (BACKEND_TYPE=zellij). Behaves like the non-tmux
68
70
  * pty path for the worker (renderer screenshots, relay web terminal) but owns
69
71
  * a persistent zellij session that survives daemon restart. */
@@ -210,6 +212,8 @@ let bridgePendingTail = '';
210
212
  const bridgeQueue = new BridgeTurnQueue();
211
213
  let bridgeWatcher = null;
212
214
  let bridgeFallbackTimer = null;
215
+ let herdrAdoptBridgeQuietTimer = null;
216
+ const HERDR_ADOPT_BRIDGE_QUIET_MS = 3_000;
213
217
  /** True once we successfully baselined the transcript file. Until then,
214
218
  * any data we see is treated as history — absorbed into the queue's seen
215
219
  * set without being attributed to a pending Lark turn. This protects the
@@ -421,6 +425,37 @@ function bridgeProbeOpenSessionIds() {
421
425
  for (const path of opened)
422
426
  bridgeRememberSessionIdForPath(path);
423
427
  }
428
+ function bridgeShouldEmitAfterTranscriptQuiet() {
429
+ return lastInitConfig?.adoptMode === true
430
+ && lastInitConfig?.adoptSource === 'herdr'
431
+ && lastInitConfig?.cliId === 'claude-code'
432
+ && !!bridgeJsonlPath;
433
+ }
434
+ function clearHerdrAdoptBridgeQuietTimer() {
435
+ if (!herdrAdoptBridgeQuietTimer)
436
+ return;
437
+ clearTimeout(herdrAdoptBridgeQuietTimer);
438
+ herdrAdoptBridgeQuietTimer = null;
439
+ }
440
+ function scheduleHerdrAdoptBridgeQuietEmit() {
441
+ if (!bridgeShouldEmitAfterTranscriptQuiet())
442
+ return;
443
+ clearHerdrAdoptBridgeQuietTimer();
444
+ herdrAdoptBridgeQuietTimer = setTimeout(() => {
445
+ herdrAdoptBridgeQuietTimer = null;
446
+ if (!bridgeShouldEmitAfterTranscriptQuiet())
447
+ return;
448
+ try {
449
+ bridgeDrainAndMaybeEmit();
450
+ markPromptReady();
451
+ log('Bridge quiet emit attempted — herdr adopt mode');
452
+ }
453
+ catch (err) {
454
+ log(`Bridge quiet emit error: ${err.message}`);
455
+ }
456
+ }, HERDR_ADOPT_BRIDGE_QUIET_MS);
457
+ herdrAdoptBridgeQuietTimer.unref?.();
458
+ }
424
459
  function bridgeAbsorbBaseline() {
425
460
  if (!bridgeJsonlPath)
426
461
  return;
@@ -523,7 +558,7 @@ function bridgeApplyFingerprintSwitch(matched, reason, cutoffMs) {
523
558
  try {
524
559
  bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
525
560
  try {
526
- bridgeIngest();
561
+ performBridgeIngestAndScheduleQuietEmit();
527
562
  }
528
563
  catch (err) {
529
564
  log(`Bridge ingest error: ${err.message}`);
@@ -800,7 +835,7 @@ function performRotationSwitch(newPath, cutoffMs, reason) {
800
835
  try {
801
836
  bridgeWatcher = fsWatch(newPath, { persistent: false }, () => {
802
837
  try {
803
- bridgeIngest();
838
+ performBridgeIngestAndScheduleQuietEmit();
804
839
  }
805
840
  catch (err) {
806
841
  log(`Bridge ingest error: ${err.message}`);
@@ -985,7 +1020,7 @@ function maybeFollowSessionRotationViaPid() {
985
1020
  try {
986
1021
  bridgeWatcher = fsWatch(resolved.path, { persistent: false }, () => {
987
1022
  try {
988
- bridgeIngest();
1023
+ performBridgeIngestAndScheduleQuietEmit();
989
1024
  }
990
1025
  catch (err) {
991
1026
  log(`Bridge ingest error: ${err.message}`);
@@ -1083,6 +1118,14 @@ function bridgeIngest() {
1083
1118
  bridgePendingTail = result.pendingTail;
1084
1119
  bridgeQueue.ingest(result.events, bridgeJsonlPath);
1085
1120
  }
1121
+ function performBridgeIngestAndScheduleQuietEmit() {
1122
+ const beforePath = bridgeJsonlPath;
1123
+ const beforeOffset = bridgeOffset;
1124
+ bridgeIngest();
1125
+ if (bridgeJsonlPath && (bridgeJsonlPath !== beforePath || bridgeOffset > beforeOffset)) {
1126
+ scheduleHerdrAdoptBridgeQuietEmit();
1127
+ }
1128
+ }
1086
1129
  function startBridgeWatcher(jsonlPath, opts) {
1087
1130
  bridgeJsonlPath = jsonlPath;
1088
1131
  bridgeJsonlDir = dirname(jsonlPath);
@@ -1172,7 +1215,7 @@ function startBridgeWatcher(jsonlPath, opts) {
1172
1215
  try {
1173
1216
  bridgeWatcher = fsWatch(bridgeJsonlPath, { persistent: false }, () => {
1174
1217
  try {
1175
- bridgeIngest();
1218
+ performBridgeIngestAndScheduleQuietEmit();
1176
1219
  }
1177
1220
  catch (err) {
1178
1221
  log(`Bridge ingest error: ${err.message}`);
@@ -1184,7 +1227,7 @@ function startBridgeWatcher(jsonlPath, opts) {
1184
1227
  }
1185
1228
  bridgeFallbackTimer = setInterval(() => {
1186
1229
  try {
1187
- bridgeIngest();
1230
+ performBridgeIngestAndScheduleQuietEmit();
1188
1231
  }
1189
1232
  catch (err) {
1190
1233
  log(`Bridge ingest error: ${err.message}`);
@@ -1192,6 +1235,7 @@ function startBridgeWatcher(jsonlPath, opts) {
1192
1235
  }, 1000);
1193
1236
  }
1194
1237
  function stopBridgeWatcher() {
1238
+ clearHerdrAdoptBridgeQuietTimer();
1195
1239
  if (bridgeWatcher) {
1196
1240
  try {
1197
1241
  bridgeWatcher.close();
@@ -2776,7 +2820,170 @@ function stopScreenUpdates() {
2776
2820
  lastAnalyzerSnapshot = '';
2777
2821
  }
2778
2822
  // ─── PTY Management ──────────────────────────────────────────────────────────
2823
+ function setupAdoptTranscriptBridges(cfg) {
2824
+ if (cfg.bridgeJsonlPath) {
2825
+ startBridgeWatcher(cfg.bridgeJsonlPath, {
2826
+ cliPid: cfg.adoptCliPid,
2827
+ cliCwd: cfg.adoptCwd,
2828
+ });
2829
+ }
2830
+ else if (cfg.cliId === 'codex') {
2831
+ const adoptStartMs = Date.now();
2832
+ codexAdoptStartMs = adoptStartMs;
2833
+ codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2834
+ let rolloutPath;
2835
+ if (cfg.cliSessionId)
2836
+ rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
2837
+ if (!rolloutPath && cfg.adoptCliPid) {
2838
+ const probed = findCodexRolloutByPid(cfg.adoptCliPid);
2839
+ if (probed)
2840
+ rolloutPath = probed.path;
2841
+ }
2842
+ if (rolloutPath) {
2843
+ codexBridgeAttach(rolloutPath, 'split-live');
2844
+ }
2845
+ else {
2846
+ if (cfg.cliSessionId)
2847
+ codexBridgePendingSessionId = cfg.cliSessionId;
2848
+ codexAdoptPendingPid = cfg.adoptCliPid;
2849
+ codexBridgeStartTimer();
2850
+ }
2851
+ }
2852
+ else if (cfg.cliId === 'coco') {
2853
+ const adoptStartMs = Date.now();
2854
+ codexAdoptStartMs = adoptStartMs;
2855
+ codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2856
+ let eventsPath;
2857
+ if (cfg.cliSessionId)
2858
+ eventsPath = cocoEventsPathForSession(cfg.cliSessionId);
2859
+ if (!eventsPath && cfg.adoptCliPid) {
2860
+ const probed = findCocoSessionByPid(cfg.adoptCliPid);
2861
+ if (probed)
2862
+ eventsPath = probed.eventsPath;
2863
+ }
2864
+ if (eventsPath) {
2865
+ const sessionDir = dirname(eventsPath);
2866
+ if (!existsSync(sessionDir)) {
2867
+ send({
2868
+ type: 'final_output',
2869
+ content: '⚠️ 当前 CoCo 进程的会话目录已被删除(可能是 e2e 测试清理或手动 rm),写到 events.jsonl 的内容会落到一个失效 inode 上,桥接读不到。请重启 CoCo 后重新 /adopt。',
2870
+ lastUuid: `coco-adopt-stale-${randomBytes(4).toString('hex')}`,
2871
+ turnId: 'coco-adopt-stale',
2872
+ });
2873
+ log(`CoCo adopt: session dir missing, bridge disabled (${sessionDir})`);
2874
+ }
2875
+ else {
2876
+ codexBridgeAttach(eventsPath, 'split-live');
2877
+ }
2878
+ }
2879
+ else {
2880
+ codexAdoptPendingPid = cfg.adoptCliPid;
2881
+ }
2882
+ codexBridgeStartTimer();
2883
+ }
2884
+ else if (cfg.cliId === 'mtr') {
2885
+ const adoptStartMs = Date.now();
2886
+ codexAdoptStartMs = adoptStartMs;
2887
+ codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2888
+ if (cfg.cliSessionId)
2889
+ codexBridgePendingSessionId = cfg.cliSessionId;
2890
+ const source = findMtrSessionById(cfg.cliSessionId)
2891
+ ?? findLatestMtrSessionByDirectory(cfg.adoptCwd ?? cfg.workingDir);
2892
+ if (source) {
2893
+ codexBridgePendingSessionId = undefined;
2894
+ mtrBridgeAttach(source, 'split-live');
2895
+ }
2896
+ else {
2897
+ codexBridgeStartTimer();
2898
+ }
2899
+ }
2900
+ }
2901
+ function adoptIdleAdapter(cfg) {
2902
+ return cfg.bridgeJsonlPath
2903
+ ? createCliAdapterSync('claude-code', undefined)
2904
+ : cfg.cliId === 'codex' || cfg.cliId === 'coco' || cfg.cliId === 'mtr'
2905
+ ? createCliAdapterSync(cfg.cliId, undefined)
2906
+ : { completionPattern: undefined, readyPattern: undefined };
2907
+ }
2908
+ function setupAdoptInputAdapter(cfg) {
2909
+ if (cfg.cliId === 'codex') {
2910
+ cliAdapter = createCliAdapterSync('codex', cfg.cliPathOverride);
2911
+ }
2912
+ else if (cfg.cliId === 'mtr') {
2913
+ cliAdapter = createCliAdapterSync('mtr', cfg.cliPathOverride);
2914
+ }
2915
+ }
2916
+ function setupAdoptIdleDetection(cfg, label) {
2917
+ idleDetector = new IdleDetector(adoptIdleAdapter(cfg));
2918
+ idleDetector.onIdle(() => {
2919
+ log(`Prompt detected (idle) — ${label} adopt mode`);
2920
+ try {
2921
+ bridgeDrainAndMaybeEmit();
2922
+ }
2923
+ catch (err) {
2924
+ log(`Bridge emit error: ${err.message}`);
2925
+ }
2926
+ try {
2927
+ codexBridgeDrainAndMaybeEmit();
2928
+ }
2929
+ catch (err) {
2930
+ log(`Codex bridge emit error: ${err.message}`);
2931
+ }
2932
+ markPromptReady();
2933
+ });
2934
+ }
2935
+ function seedBackendScreen(source, be) {
2936
+ try {
2937
+ const initial = be.captureCurrentScreen?.() ?? '';
2938
+ if (initial.length > 0)
2939
+ onPtyData(initial);
2940
+ }
2941
+ catch (err) {
2942
+ log(`${source} captureCurrentScreen failed: ${err.message}`);
2943
+ }
2944
+ }
2779
2945
  function spawnCli(cfg) {
2946
+ // ── Adopt mode: observe the user's existing terminal backend (no attach) ──
2947
+ if (cfg.adoptMode && cfg.adoptSource === 'herdr' && cfg.adoptHerdrSessionName && (cfg.adoptHerdrPaneId || cfg.adoptHerdrTarget)) {
2948
+ isTmuxMode = false;
2949
+ isPipeMode = true;
2950
+ isZellijMode = false;
2951
+ const cols = cfg.adoptPaneCols ?? PTY_COLS;
2952
+ const rows = cfg.adoptPaneRows ?? PTY_ROWS;
2953
+ const target = cfg.adoptHerdrTarget ?? cfg.adoptHerdrPaneId;
2954
+ const herdrBe = new HerdrBackend(cfg.adoptHerdrSessionName, {
2955
+ externalTarget: {
2956
+ sessionName: cfg.adoptHerdrSessionName,
2957
+ target,
2958
+ paneId: cfg.adoptHerdrPaneId,
2959
+ },
2960
+ });
2961
+ effectiveBackendType = 'herdr';
2962
+ backend = herdrBe;
2963
+ herdrBe.spawn('', [], {
2964
+ cwd: cfg.workingDir,
2965
+ cols,
2966
+ rows,
2967
+ env: process.env,
2968
+ });
2969
+ seedBackendScreen('herdr adopt', herdrBe);
2970
+ setupAdoptTranscriptBridges(cfg);
2971
+ setupAdoptInputAdapter(cfg);
2972
+ setupAdoptIdleDetection(cfg, 'herdr');
2973
+ backend.onData(onPtyData);
2974
+ backend.onExit((code, signal) => {
2975
+ log(`Adopted herdr stream ended (code: ${code}, signal: ${signal})`);
2976
+ backend = null;
2977
+ isPromptReady = false;
2978
+ stopBridgeWatcher();
2979
+ send({ type: 'claude_exit', code, signal });
2980
+ });
2981
+ awaitingFirstPrompt = false;
2982
+ renderer?.markNewTurn();
2983
+ log(`Adopt mode (herdr): observing ${cfg.adoptHerdrSessionName}:${target} (${cols}x${rows})`);
2984
+ return;
2985
+ }
2986
+ // ── Adopt mode: pipe-pane the user's existing tmux pane (no attach) ──
2780
2987
  // ── Adopt mode: observe the user's existing pane (no attach / non-invasive) ──
2781
2988
  // tmux: pipe-pane (raw stream). zellij: dump-screen poll + action drive.
2782
2989
  if (cfg.adoptMode && (cfg.adoptTmuxTarget || cfg.adoptZellijPaneId)) {
@@ -2786,11 +2993,13 @@ function spawnCli(cfg) {
2786
2993
  // PTY-per-WS — we don't attach to anything).
2787
2994
  isTmuxMode = true;
2788
2995
  isPipeMode = true;
2996
+ isZellijMode = !!cfg.adoptZellijPaneId;
2789
2997
  const cols = cfg.adoptPaneCols ?? PTY_COLS;
2790
2998
  const rows = cfg.adoptPaneRows ?? PTY_ROWS;
2791
2999
  const observeBe = cfg.adoptZellijPaneId
2792
3000
  ? new ZellijObserveBackend(cfg.adoptZellijSession ?? '', cfg.adoptZellijPaneId, { cliPid: cfg.adoptCliPid })
2793
3001
  : new TmuxPipeBackend(cfg.adoptTmuxTarget);
3002
+ effectiveBackendType = cfg.adoptZellijPaneId ? 'zellij' : 'tmux';
2794
3003
  backend = observeBe;
2795
3004
  observeBe.spawn('', [], {
2796
3005
  cwd: cfg.workingDir,
@@ -2801,162 +3010,10 @@ function spawnCli(cfg) {
2801
3010
  // Seed the shared scrollback with the pane's current screen so any
2802
3011
  // already-connected (or future) WS clients render meaningful content
2803
3012
  // immediately, instead of waiting for the next observe tick.
2804
- try {
2805
- const initial = observeBe.captureCurrentScreen();
2806
- if (initial.length > 0)
2807
- onPtyData(initial);
2808
- }
2809
- catch (err) {
2810
- log(`captureCurrentScreen failed: ${err.message}`);
2811
- }
2812
- // Bridge mode: tail the adopted CLI's transcript to harvest assistant
2813
- // turns out-of-band. Two paths:
2814
- // - claude-code: cfg.bridgeJsonlPath is set when adopt knew the sid.
2815
- // - codex: locate rollout via cliSessionId (daemon's discovery probe)
2816
- // or by reading /proc/<pid>/fd. Both modes enable adopt-only local
2817
- // turn synthesis so iTerm-typed conversation also reaches Lark.
2818
- if (cfg.bridgeJsonlPath) {
2819
- startBridgeWatcher(cfg.bridgeJsonlPath, {
2820
- cliPid: cfg.adoptCliPid,
2821
- cliCwd: cfg.adoptCwd,
2822
- });
2823
- }
2824
- else if (cfg.cliId === 'codex') {
2825
- const adoptStartMs = Date.now();
2826
- codexAdoptStartMs = adoptStartMs;
2827
- codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2828
- let rolloutPath;
2829
- if (cfg.cliSessionId)
2830
- rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
2831
- if (!rolloutPath && cfg.adoptCliPid) {
2832
- const probed = findCodexRolloutByPid(cfg.adoptCliPid);
2833
- if (probed)
2834
- rolloutPath = probed.path;
2835
- }
2836
- if (rolloutPath) {
2837
- // Adopt-time attach: split-live so any iTerm activity that
2838
- // happened in the brief window between adopt detection and worker
2839
- // spawn (or between codex's own startup writes and now) lands as
2840
- // live, not absorbed history.
2841
- codexBridgeAttach(rolloutPath, 'split-live');
2842
- }
2843
- else {
2844
- // Couldn't locate yet — start poller. The 1s timer keeps trying
2845
- // both findCodexRolloutBySessionId (if cliSessionId is set) and
2846
- // findCodexRolloutByPid (passed via the discovery hooks below).
2847
- if (cfg.cliSessionId)
2848
- codexBridgePendingSessionId = cfg.cliSessionId;
2849
- codexAdoptPendingPid = cfg.adoptCliPid;
2850
- codexBridgeStartTimer();
2851
- }
2852
- }
2853
- else if (cfg.cliId === 'coco') {
2854
- // CoCo adopt: parallel to codex, but the events.jsonl path is
2855
- // deterministic from cliSessionId, so once the daemon-side discovery
2856
- // surfaced an sid we know the path immediately. The file may not
2857
- // exist yet (CoCo creates it on first event); codexBridgeAttach's
2858
- // split-live-with-missing-file branch degrades to fresh, and the
2859
- // late-attach poller catches re-creation.
2860
- const adoptStartMs = Date.now();
2861
- codexAdoptStartMs = adoptStartMs;
2862
- codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2863
- let eventsPath;
2864
- if (cfg.cliSessionId)
2865
- eventsPath = cocoEventsPathForSession(cfg.cliSessionId);
2866
- if (!eventsPath && cfg.adoptCliPid) {
2867
- const probed = findCocoSessionByPid(cfg.adoptCliPid);
2868
- if (probed)
2869
- eventsPath = probed.eventsPath;
2870
- }
2871
- if (eventsPath) {
2872
- // If the session DIRECTORY is missing (not just events.jsonl), CoCo
2873
- // is operating on an unlinked inode — common after an e2e test or
2874
- // manual cleanup wiped the dir while CoCo kept its fds open. The
2875
- // bridge file will never appear, so warn the user once via Lark
2876
- // instead of polling forever in silence.
2877
- const sessionDir = dirname(eventsPath);
2878
- if (!existsSync(sessionDir)) {
2879
- send({
2880
- type: 'final_output',
2881
- content: '⚠️ 当前 CoCo 进程的会话目录已被删除(可能是 e2e 测试清理或手动 rm),写到 events.jsonl 的内容会落到一个失效 inode 上,桥接读不到。请重启 CoCo 后重新 /adopt。',
2882
- lastUuid: `coco-adopt-stale-${randomBytes(4).toString('hex')}`,
2883
- turnId: 'coco-adopt-stale',
2884
- });
2885
- log(`CoCo adopt: session dir missing, bridge disabled (${sessionDir})`);
2886
- }
2887
- else {
2888
- codexBridgeAttach(eventsPath, 'split-live');
2889
- }
2890
- }
2891
- else {
2892
- // No sid known yet — fall back to PID-walk in the late-attach
2893
- // poller. Reuses codexAdoptPendingPid since the timer dispatches
2894
- // by cliId at probe time (see codexBridgeStartTimer).
2895
- codexAdoptPendingPid = cfg.adoptCliPid;
2896
- }
2897
- // Always run the bridge poller for CoCo adopt — events.jsonl is created
2898
- // lazily on first event, so fs.watch typically ENOENTs at attach time.
2899
- // The 1s timer covers ingest + emit even when the watcher never armed,
2900
- // and is idempotent (no-op if already started).
2901
- codexBridgeStartTimer();
2902
- }
2903
- else if (cfg.cliId === 'mtr') {
2904
- const adoptStartMs = Date.now();
2905
- codexAdoptStartMs = adoptStartMs;
2906
- codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2907
- if (cfg.cliSessionId)
2908
- codexBridgePendingSessionId = cfg.cliSessionId;
2909
- const source = findMtrSessionById(cfg.cliSessionId)
2910
- ?? findLatestMtrSessionByDirectory(cfg.adoptCwd ?? cfg.workingDir);
2911
- if (source) {
2912
- codexBridgePendingSessionId = undefined;
2913
- mtrBridgeAttach(source, 'split-live');
2914
- }
2915
- else {
2916
- codexBridgeStartTimer();
2917
- }
2918
- }
2919
- // Idle detection. In bridge mode we use the adopted CLI's real
2920
- // completion/ready patterns (e.g. "Worked for Xs") so tool-execution
2921
- // pauses don't trigger a premature emit. Other adopt cases keep the
2922
- // minimal output-quiescence-only detector.
2923
- const idleAdapter = cfg.bridgeJsonlPath
2924
- ? createCliAdapterSync('claude-code', undefined)
2925
- : cfg.cliId === 'codex' || cfg.cliId === 'coco' || cfg.cliId === 'mtr'
2926
- ? createCliAdapterSync(cfg.cliId, undefined)
2927
- : { completionPattern: undefined, readyPattern: undefined };
2928
- idleDetector = new IdleDetector(idleAdapter);
2929
- // Codex adopt write path: route Lark messages through the codex
2930
- // adapter's writeInput so they pick up the 200 ms paste-detection
2931
- // delay + Enter-retry + ~/.codex/history.jsonl verification loop
2932
- // (see src/adapters/cli/codex.ts:125-178). Without it, Codex TUI's
2933
- // "\n treated as Enter" handling leaves multi-line submits stuck
2934
- // in the input box. Other adopt CLIs keep the simpler raw
2935
- // sendText+Enter path — claude-code adopt has its own bridge
2936
- // verify path; gemini / coco / opencode / aiden haven't surfaced
2937
- // this failure mode and we don't want to risk regressing them.
2938
- if (cfg.cliId === 'codex') {
2939
- cliAdapter = createCliAdapterSync('codex', cfg.cliPathOverride);
2940
- }
2941
- else if (cfg.cliId === 'mtr') {
2942
- cliAdapter = createCliAdapterSync('mtr', cfg.cliPathOverride);
2943
- }
2944
- idleDetector.onIdle(() => {
2945
- log('Prompt detected (idle) — adopt mode');
2946
- try {
2947
- bridgeDrainAndMaybeEmit();
2948
- }
2949
- catch (err) {
2950
- log(`Bridge emit error: ${err.message}`);
2951
- }
2952
- try {
2953
- codexBridgeDrainAndMaybeEmit();
2954
- }
2955
- catch (err) {
2956
- log(`Codex bridge emit error: ${err.message}`);
2957
- }
2958
- markPromptReady();
2959
- });
3013
+ seedBackendScreen(`${effectiveBackendType} adopt`, observeBe);
3014
+ setupAdoptTranscriptBridges(cfg);
3015
+ setupAdoptIdleDetection(cfg, 'pipe');
3016
+ setupAdoptInputAdapter(cfg);
2960
3017
  backend.onData(onPtyData);
2961
3018
  backend.onExit((code, signal) => {
2962
3019
  log(`Adopted pipe-pane stream ended (code: ${code}, signal: ${signal})`);
@@ -2967,7 +3024,8 @@ function spawnCli(cfg) {
2967
3024
  });
2968
3025
  awaitingFirstPrompt = false;
2969
3026
  renderer?.markNewTurn();
2970
- log(`Adopt mode (pipe): observing ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
3027
+ const target = cfg.adoptZellijPaneId ? `${cfg.adoptZellijSession}/${cfg.adoptZellijPaneId}` : cfg.adoptTmuxTarget;
3028
+ log(`Adopt mode (${effectiveBackendType}): observing ${target} (${cols}x${rows})`);
2971
3029
  return;
2972
3030
  }
2973
3031
  cliAdapter = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride);
@@ -2981,10 +3039,15 @@ function spawnCli(cfg) {
2981
3039
  log('tmux backend requested but functional probe failed — falling back to PTY backend');
2982
3040
  effectiveBackend = 'pty';
2983
3041
  }
3042
+ if (effectiveBackend === 'herdr' && !HerdrBackend.isAvailable()) {
3043
+ log('herdr backend requested but probe failed — falling back to PTY backend');
3044
+ effectiveBackend = 'pty';
3045
+ }
2984
3046
  if (effectiveBackend === 'zellij' && !ZellijBackend.isAvailable()) {
2985
3047
  log('zellij backend requested but functional probe failed (need zellij >= 0.44) — falling back to PTY backend');
2986
3048
  effectiveBackend = 'pty';
2987
3049
  }
3050
+ effectiveBackendType = effectiveBackend;
2988
3051
  const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, backendType: effectiveBackend });
2989
3052
  isTmuxMode = selectedBackend.isTmuxMode;
2990
3053
  isPipeMode = selectedBackend.isPipeMode;
@@ -3049,13 +3112,22 @@ function spawnCli(cfg) {
3049
3112
  // just `tmux attach-session`s — logging `Spawning: <new bin>` in that case
3050
3113
  // is misleading and has cost real debugging time. (CliId-mismatch reattach
3051
3114
  // is now blocked upstream in restoreActiveSessions / killStalePids.)
3052
- const willReattachTmux = isTmuxMode && TmuxBackend.hasSession(TmuxBackend.sessionName(cfg.sessionId));
3053
- const willReattachZellij = isZellijMode && ZellijBackend.hasSession(ZellijBackend.sessionName(cfg.sessionId));
3054
- if (willReattachTmux) {
3055
- log(`Re-attaching to existing tmux session: ${TmuxBackend.sessionName(cfg.sessionId)} (requested CLI: ${cliAdapter.resolvedBin})`);
3056
- }
3057
- else if (willReattachZellij) {
3058
- log(`Re-attaching to existing zellij session: ${ZellijBackend.sessionName(cfg.sessionId)} (requested CLI: ${cliAdapter.resolvedBin})`);
3115
+ const persistentSessionName = effectiveBackendType === 'tmux'
3116
+ ? TmuxBackend.sessionName(cfg.sessionId)
3117
+ : effectiveBackendType === 'herdr'
3118
+ ? HerdrBackend.sessionName(cfg.sessionId)
3119
+ : effectiveBackendType === 'zellij'
3120
+ ? ZellijBackend.sessionName(cfg.sessionId)
3121
+ : undefined;
3122
+ const willReattachPersistent = persistentSessionName
3123
+ ? effectiveBackendType === 'tmux'
3124
+ ? TmuxBackend.hasSession(persistentSessionName)
3125
+ : effectiveBackendType === 'zellij'
3126
+ ? ZellijBackend.hasSession(persistentSessionName)
3127
+ : HerdrBackend.hasSession(persistentSessionName)
3128
+ : false;
3129
+ if (willReattachPersistent) {
3130
+ log(`Re-attaching to existing ${effectiveBackendType} session: ${persistentSessionName} (requested CLI: ${cliAdapter.resolvedBin})`);
3059
3131
  }
3060
3132
  else {
3061
3133
  log(`Spawning fresh CLI: ${cliAdapter.resolvedBin} ${args.join(' ')} (cwd: ${cfg.workingDir})`);
@@ -3253,16 +3325,9 @@ function spawnCli(cfg) {
3253
3325
  isPromptReady = false;
3254
3326
  send({ type: 'claude_exit', code, signal });
3255
3327
  });
3256
- if (isPipeMode && backend instanceof TmuxPipeBackend && backend.isReattach) {
3257
- log(`Re-attached to existing tmux session via pipe-pane: ${TmuxBackend.sessionName(cfg.sessionId)}`);
3258
- try {
3259
- const initial = backend.captureCurrentScreen();
3260
- if (initial.length > 0)
3261
- onPtyData(initial);
3262
- }
3263
- catch (err) {
3264
- log(`captureCurrentScreen failed: ${err.message}`);
3265
- }
3328
+ if (isPipeMode && backend && 'isReattach' in backend && backend.isReattach) {
3329
+ log(`Re-attached to existing ${effectiveBackendType} session via pipe backend: ${persistentSessionName}`);
3330
+ seedBackendScreen(`${effectiveBackendType} reattach`, backend);
3266
3331
  }
3267
3332
  // Fallback: if the CLI takes too long to show its prompt (e.g. slow
3268
3333
  // plugin init), unblock screen updates so the card doesn't stay at
@@ -4014,6 +4079,13 @@ process.on('message', async (raw) => {
4014
4079
  break;
4015
4080
  }
4016
4081
  log('Restart requested');
4082
+ // Must destroySession(), not kill(): for persistent backends (tmux/herdr)
4083
+ // kill() only detaches — the backing session + CLI process keep running,
4084
+ // so the resume:true spawnCli below would re-attach to the SAME live CLI
4085
+ // (selectSessionBackend reattaches whenever hasSession() is true) and the
4086
+ // process would never actually restart. destroySession() tears the session
4087
+ // down so the respawn starts a fresh CLI. (PTY has no destroySession, so
4088
+ // the ?. no-ops and killCli()'s kill() does the teardown.)
4017
4089
  backend?.destroySession?.();
4018
4090
  killCli();
4019
4091
  awaitingFirstPrompt = true;